release: 1.0.0: Public 1.0 image; delivery log; docs and SECURITY.md (incl. 1.0.1)

[1.0.0]
Added:
  - A **Delivery log** on each delivery's page (`/deliveries/{id}`): the
  - A **History** block on the same page: the journal's two timestamps stated as
Fixed:
  - Postfix config: copy TLS cert/key from the (often `:ro`, host-owned) mount
  - Postfix config: allow `maillog_file` under `/data` via
  - E2e: read `/data/setup-token` via `docker compose exec` (file is `0600`
  - Entrypoint: check `SELFPOST_HOSTNAME` before `/data` setup (so a bad identity
  - E2e gate: wait for host-published `/healthz` before panel setup, and stop
  … (3 more in CHANGELOG)
Changed:
  - Document what `.spbk` and `.spde` stand for (SelfPost backup / SelfPost domain
  - Docs aligned with the code: setup URL is `/setup/<token>` (README and
  - Full backups no longer carry `/data/log`. It is Postfix's raw log plus its
  - Monitoring screens (status, mail queue, system log, deliveries) use adaptive
  - Documentation package consolidated into `docs/development.md`: Documentation
  … (4 more in CHANGELOG)
[1.0.1]
A documentation and packaging release: no change to the mail path, the database, or the on-disk layout. Upgrading is a tag bump.
Added:
  - `SECURITY.md` — how to report a vulnerability privately (GitHub private
  - [docs/plans/](docs/plans/) — one document per agreed extension: the optional
Changed:
  - The panel's **Account** entry is now called **Settings** — nav link, page
  - The signed-in name in the panel's nav is now labelled `User:`, so it reads as
  - [docs/product.md](docs/product.md) reframes the future line: the inbound
  - [docs/security.md](docs/security.md) is now in English, matching the rest of
  - The README documentation table now points at `SECURITY.md` for reporting a
  … (2 more in CHANGELOG)
Fixed:
  - The panel's static assets are served with a content ETag and

Squashed CHANGELOG sections: [1.0.0], [1.0.1].

Full detail: CHANGELOG.md (1.0.0, 1.0.1).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
parent 83c488cc48
commit 75207f0d1e
80 changed files with 3670 additions and 1662 deletions
+1 -2
View File
@@ -12,8 +12,7 @@ import (
// SASLDB manages the Cyrus SASL account database (sasldb2) the panel maintains
// for application credentials (architecture.md § Mail path). The panel is the
// only writer; Postfix reads it to authenticate SMTP clients. Accounts are
// created and removed with the standard saslpasswd2 tool ("эквивалент
// saslpasswd2", per the plan).
// created and removed with the standard saslpasswd2 tool.
type SASLDB struct {
path string // sasldb2 file, under /data so it survives restarts
realm string // SASL realm, so lookups match what Postfix's SASL uses
+3 -3
View File
@@ -174,7 +174,7 @@ func (s *Service) Delete(id int64) error {
if err := s.sasl.Delete(a.Login); err != nil {
return err
}
// Drop the application's level-2 limit, if any (README § Rate limiting);
// Drop the application's level-2 limit, if any (guide § Rate limiting);
// rate_limits has no cascade of its own.
if err := s.store.DeleteRateLimit(store.RateLimitScopeApp, id); err != nil {
return err
@@ -182,7 +182,7 @@ func (s *Service) Delete(id int64) error {
return s.Resync()
}
// RateLimit returns the application-level differentiated rate limit (README §
// RateLimit returns the application-level differentiated rate limit (guide §
// Rate limiting), and whether one is configured, for the application's edit
// form.
func (s *Service) RateLimit(appID int64) (store.RateLimit, bool, error) {
@@ -202,7 +202,7 @@ func (s *Service) SaveRateLimit(appID int64, ips []string, maxMessages, windowSe
})
}
// ClearRateLimit removes the application-level rate limit (README § Rate
// ClearRateLimit removes the application-level rate limit (guide § Rate
// limiting).
func (s *Service) ClearRateLimit(appID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeApp, appID)
+7 -4
View File
@@ -63,10 +63,12 @@ type Params struct {
// The live database files are replaced by a consistent VACUUM INTO snapshot
// written under the canonical name; the setup token is transient bootstrap
// state; a stale manifest from a previous restore must not be re-captured (a
// fresh one is written instead); and a "tls" directory holds the reverse
// proxy's certificates, which are explicitly out of scope for a SelfPost
// backup (architecture.md § Persistence) — excluding it keeps that guarantee
// even when an operator points TLS_CERT_FILE inside /data.
// fresh one is written instead); a "tls" directory holds the reverse proxy's
// certificates, which are explicitly out of scope for a SelfPost backup
// (architecture.md § Persistence) — excluding it keeps that guarantee even when
// an operator points TLS_CERT_FILE inside /data; and "log" is Postfix's raw
// delivery log plus its fourteen rotated files, which is diagnostic output, not
// state to restore, and by far the largest thing under /data.
var excludedFromArchive = map[string]bool{
"selfpost.db": true,
"selfpost.db-wal": true,
@@ -74,6 +76,7 @@ var excludedFromArchive = map[string]bool{
"selfpost.db-journal": true,
"setup-token": true,
"tls": true,
"log": true,
ManifestName: true,
}
+8 -1
View File
@@ -39,6 +39,10 @@ func seedDataDir(t *testing.T) (dataDir, dbPath string) {
writeFile(t, filepath.Join(dataDir, "setup-token"), "secret-token")
writeFile(t, filepath.Join(dataDir, "selfpost.db-wal"), "wal")
writeFile(t, filepath.Join(dataDir, "selfpost.db-shm"), "shm")
// Postfix's delivery log and its rotated files: diagnostic output, not
// state, and the bulkiest thing under /data.
writeFile(t, filepath.Join(dataDir, "log", "mail.log"), "Aug 8 07:26:41 mail postfix/smtp[1]: ABC: to=<a@example.net>, status=sent (ok)")
writeFile(t, filepath.Join(dataDir, "log", "mail.log.1"), "older")
return dataDir, dbPath
}
@@ -104,7 +108,10 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
}
}
// Excluded.
for _, name := range []string{"setup-token", "selfpost.db-wal", "selfpost.db-shm"} {
for _, name := range []string{
"setup-token", "selfpost.db-wal", "selfpost.db-shm",
"log/mail.log", "log/mail.log.1",
} {
if _, ok := files[name]; ok {
t.Errorf("archive should not contain %s", name)
}
+4 -4
View File
@@ -41,7 +41,7 @@ type Service struct {
}
// NewService builds the domain service. selectorDefault is the DKIM selector
// assigned to new domains (README § Environment variables:
// assigned to new domains (guide § Environment variables:
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
// used only on deletion, to clear the SASL accounts and sender-map bindings of
// the domain's applications.
@@ -111,7 +111,7 @@ func (s *Service) Delete(id int64) error {
}
// Drop the domain's own level-2 limit and those of its applications while the
// application rows still exist (the cleanup query joins them). rate_limits
// has no cascade of its own (ref_id is a plain integer, README § Rate
// has no cascade of its own (ref_id is a plain integer, guide § Rate
// limiting; architecture.md § Persistence).
if err := s.store.DeleteRateLimitsForDomain(id); err != nil {
return fmt.Errorf("clear rate limits for %s: %w", d.Name, err)
@@ -138,7 +138,7 @@ func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
return s.odk.Record(d.Name, d.DKIMSelector)
}
// RateLimit returns the domain-level differentiated rate limit (README § Rate
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
// limiting), and whether one is configured, for the domain's edit form.
func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
return s.store.GetRateLimit(store.RateLimitScopeDomain, domainID)
@@ -158,7 +158,7 @@ func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windo
}
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
// only (README § Rate limiting).
// only (guide § Rate limiting).
func (s *Service) ClearRateLimit(domainID int64) error {
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
}
+1 -1
View File
@@ -14,7 +14,7 @@ import (
const certWarnDays = 14
// Certificate is the state of the TLS certificate Postfix serves on 465/587
// (README § Environment variables: TLS_CERT_FILE). The panel only reads it —
// (guide § Environment variables: TLS_CERT_FILE). The panel only reads it —
// the file is supplied by the reverse proxy through a read-only mount.
type Certificate struct {
Path string
+245 -5
View File
@@ -16,16 +16,20 @@ import (
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
)
// StatusStore is the slice of the store the log-tailer needs: advancing
// delivery statuses, pruning the retention window, and remembering how far into
// mail.log it has read. *store.Store satisfies it.
// delivery statuses, finding the rows still waiting for one, pruning the
// retention window, and remembering how far into mail.log it has read.
// *store.Store satisfies it.
type StatusStore interface {
UpdateStatus(queueID, recipient, status string) (int64, error)
ListQueuedOlderThan(cutoff time.Time) ([]store.QueuedDelivery, error)
DeleteSendLogBefore(cutoff time.Time) (int64, error)
LogtailState(path string) (store.LogtailState, bool, error)
SaveLogtailState(path string, st store.LogtailState) error
@@ -35,13 +39,24 @@ type StatusStore interface {
// is a var so tests can shorten it.
var pollInterval = time.Second
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix.
var queueIDs = postfix.QueueIDs
const (
// retentionInterval is how often the retention sweep runs (also once at
// startup). The window itself is configurable; the cadence need not be.
retentionInterval = 6 * time.Hour
// defaultRetentionDays applies when the configured value is unset/invalid
// (README § Environment variables: SEND_LOG_RETENTION_DAYS).
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
defaultRetentionDays = 90
// reconcileInterval is how often the sweep compares stuck rows against the
// Postfix queue, and reconcileGrace how long a row is left alone first.
// The grace covers the ordinary lag between the milter writing the row and
// Postfix logging the result — seconds, generously rounded up — so a
// message merely in flight is never touched.
reconcileInterval = 5 * time.Minute
reconcileGrace = 2 * time.Minute
)
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient
@@ -89,7 +104,14 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error {
go retentionLoop(ctx, st, retentionDays)
return follow(ctx, path, &tracker{st: st, path: path}, func(line string) {
// The reconcile sweep must not run against a backlog the tailer has not
// read yet: on a restart the log holds the very lines that resolve the rows
// the sweep would otherwise close. follow() closes this once it has read to
// end-of-file for the first time.
caughtUp := make(chan struct{})
go reconcileLoop(ctx, st, caughtUp)
return follow(ctx, path, &tracker{st: st, path: path}, caughtUp, func(line string) {
queueID, recipient, status, ok := parseDelivery(line)
if !ok {
return
@@ -100,6 +122,75 @@ func Run(ctx context.Context, path string, st StatusStore, retentionDays int) er
})
}
// reconcileLoop periodically closes send-log rows Postfix has stopped working
// on (architecture.md § Log tailer). It starts only once the tailer has caught
// up with the log, and then leaves the first sweep a full interval away, so a
// restart resolves rows from the log — the accurate source — before the sweep
// gets to guess at whatever the log could not explain.
func reconcileLoop(ctx context.Context, st StatusStore, caughtUp <-chan struct{}) {
select {
case <-ctx.Done():
return
case <-caughtUp:
}
t := time.NewTicker(reconcileInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
reconcile(st, time.Now().UTC().Add(-reconcileGrace))
}
}
}
// reconcile marks as bounced every row still "queued" from before cutoff whose
// message Postfix no longer holds.
//
// A row reaches this state only when its delivery lines are gone for good — the
// log rotated past its fourteen files while the panel was down, or was deleted
// — since the log itself now outlives the container. Postfix having dropped the
// message means it will never report anything more about it, so the row can
// only be closed on an assumption; it is closed as a failure rather than a
// success because a delivery the panel cannot evidence must not be shown as
// one. Rows whose message is still in the queue, and every row when the queue
// cannot be listed at all, are left exactly as they are.
func reconcile(st StatusStore, cutoff time.Time) {
rows, err := st.ListQueuedOlderThan(cutoff)
if err != nil {
log.Printf("log-tailer: reconcile: list queued rows: %v", err)
return
}
if len(rows) == 0 {
return
}
held, err := queueIDs()
if err != nil {
// No listing is no information: closing rows now would be a guess made
// against nothing.
log.Printf("log-tailer: reconcile: read postfix queue: %v", err)
return
}
var closed int
for _, row := range rows {
if _, still := held[row.QueueID]; still {
continue
}
if _, err := st.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
log.Printf("log-tailer: reconcile: close %s/%s: %v", row.QueueID, row.To, err)
continue
}
closed++
}
if closed > 0 {
log.Printf("log-tailer: reconcile: closed %d row(s) Postfix no longer holds and never reported", closed)
}
}
// retentionLoop prunes expired send-log rows immediately and then periodically.
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) {
if retentionDays <= 0 {
@@ -176,12 +267,158 @@ func TailLines(path string, n int) ([]string, error) {
return lines, nil
}
// queueScanBytes bounds how far back QueueLines reads. A message's own lines
// are a handful, but they are scattered through everything else the mail path
// logged around them, so finding them means reading rather than seeking. The
// budget is what keeps that read bounded on a log that has grown for a day:
// beyond it the answer is "not in the part of the log still on disk", which is
// the same answer rotation gives and is reported the same way. It is a var so
// tests can shrink it, the way pollInterval above is.
var queueScanBytes int64 = 4 << 20
// QueueLines returns the mail.log lines Postfix wrote about one queue id,
// oldest first and at most n of them, for a single delivery's page
// (architecture.md § Panel HTTP surface). It reads the tail of the log the way
// TailLines does — one-shot, on request, unrelated to the follow loop above —
// but keeps only the lines belonging to this message instead of the last n of
// everything.
//
// A message older than the tail scanned, or older than the current log file,
// comes back empty rather than as an error: send-log rows outlive mail.log
// (retention is ninety days by default, rotation keeps fourteen files), so a
// row with nothing left to show for it is expected.
func QueueLines(path, queueID string, n int) ([]string, error) {
// No queue-id, no lines: a message the milter refused was never queued, so
// there is nothing to match on and every line would be someone else's.
if queueID == "" || n <= 0 {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
// Forward from the start of the budget rather than backwards in chunks:
// the lines are wanted oldest first, and the whole budget is read either
// way, so reading it in order costs nothing and keeps them in order.
start := info.Size() - queueScanBytes
if start < 0 {
start = 0
}
if _, err := f.Seek(start, io.SeekStart); err != nil {
return nil, err
}
sc := bufio.NewScanner(f)
// A Postfix line carrying a long remote reply can pass the scanner's default
// 64KB token; without a bigger ceiling that one line would end the scan and
// silently truncate the answer.
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
// Reading into the middle of the file lands mid-line; that fragment is
// dropped rather than reported as a line of its own.
if start > 0 && sc.Scan() {
_ = sc.Text()
}
var lines []string
for sc.Scan() {
line := sc.Text()
if !mentionsQueueID(line, queueID) {
continue
}
lines = append(lines, line)
// Keep the latest n rather than stopping at the first n: what a message
// did last is what its page is opened for.
if len(lines) > n {
lines = lines[1:]
}
}
if err := sc.Err(); err != nil {
return nil, err
}
return lines, nil
}
// mentionsQueueID reports whether a mail.log line is about queueID. Postfix
// writes the id followed by a colon ("… postfix/smtp[26]: 41E862C00D9E: to=…"),
// and the match is anchored on the character before it so a shorter id is not
// found inside a longer one — queue ids are hexadecimal, and one being the tail
// of another is ordinary, not unlikely.
func mentionsQueueID(line, queueID string) bool {
needle := queueID + ":"
for i := 0; i <= len(line)-len(needle); {
j := strings.Index(line[i:], needle)
if j < 0 {
return false
}
j += i
if j == 0 || !isQueueIDByte(line[j-1]) {
return true
}
i = j + 1
}
return false
}
func isQueueIDByte(b byte) bool {
return b >= '0' && b <= '9' || b >= 'A' && b <= 'Z' || b >= 'a' && b <= 'z'
}
// Timestamps at the head of a mail.log line, in the two formats postlogd
// writes (maillog_file in build/postfix-config.sh).
//
// syslogStampRe is the one that matches in practice today: the format is
// controlled by maillog_file_format, which arrived in Postfix 3.9, and the
// image is built on Debian's 3.7 — where the parameter does not exist and the
// only format is syslog's traditional one. It carries no year and no zone, so
// the stamp shown is a wall clock and nothing more, which is all this column
// claims to be.
//
// isoStampRe is for the RFC 3339 format that same parameter selects once the
// base image carries a Postfix new enough to offer it. Matching it first costs
// one failed anchor per line and means the upgrade needs no change here.
var (
isoStampRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\s`)
syslogStampRe = regexp.MustCompile(`^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})\s`)
)
// SplitTimestamp separates the timestamp at the head of a mail.log line from
// the rest of it, so a page can put the two in their own columns. The stamp
// comes back without its fractional seconds and zone offset — five decimal
// places of microsecond are the widest part of the column and the least worth
// reading — but is otherwise the log's own wall clock, not converted: what is
// on the page is what is in the file.
//
// A line whose head is not a timestamp this recognises comes back whole, as
// rest, with an empty stamp. Nothing is ever dropped: the point of showing the
// log is that it says what it says.
func SplitTimestamp(line string) (stamp, rest string) {
if m := isoStampRe.FindStringSubmatch(line); m != nil {
return m[1] + " " + m[2], strings.TrimSpace(line[len(m[0]):])
}
if m := syslogStampRe.FindStringSubmatch(line); m != nil {
return m[1], strings.TrimSpace(line[len(m[0]):])
}
return "", line
}
// follow tails path line by line, calling handle for each complete line, until
// ctx is cancelled. Where it starts is tr's decision (a persisted offset, the
// start of a file that changed while the panel was down, or end-of-file on a
// first ever run); it reopens the file when it is rotated (inode change from
// logrotate's create, or truncation from copytruncate) so nothing is missed.
func follow(ctx context.Context, path string, tr *tracker, handle func(string)) error {
//
// caughtUp is closed after the first read that reaches end-of-file, which is
// the point where every line the panel missed while it was down has been
// handled.
func follow(ctx context.Context, path string, tr *tracker, caughtUp chan struct{}, handle func(string)) error {
var (
f *os.File
r *bufio.Reader
@@ -255,6 +492,8 @@ func follow(ctx context.Context, path string, tr *tracker, handle func(string))
return pos - int64(len(pending))
}
var once sync.Once
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
@@ -264,6 +503,7 @@ func follow(ctx context.Context, path string, tr *tracker, handle func(string))
return nil
case <-ticker.C:
drain()
once.Do(func() { close(caughtUp) })
ni, err := os.Stat(path)
if err != nil {
continue // file briefly gone mid-rotation; try again next tick
+269
View File
@@ -2,8 +2,10 @@ package logtail
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -141,6 +143,10 @@ type captureStore struct {
state store.LogtailState
haveState bool
stateErr error
// queued is what ListQueuedOlderThan returns, for the reconcile sweep.
queued []store.QueuedDelivery
queuedErr error
}
func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, error) {
@@ -152,6 +158,12 @@ func (c *captureStore) UpdateStatus(queueID, recipient, status string) (int64, e
func (c *captureStore) DeleteSendLogBefore(time.Time) (int64, error) { return 0, nil }
func (c *captureStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.queued, c.queuedErr
}
func (c *captureStore) LogtailState(string) (store.LogtailState, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -333,3 +345,260 @@ func waitFor(t *testing.T, cond func() bool) {
}
t.Fatal("condition not met within timeout")
}
// stubQueue makes the reconcile sweep answer from a fixed list of queue ids
// instead of a running Postfix, or fail if err is non-nil.
func stubQueue(t *testing.T, err error, ids ...string) *int {
t.Helper()
calls := 0
old := queueIDs
queueIDs = func() (map[string]struct{}, error) {
calls++
if err != nil {
return nil, err
}
set := make(map[string]struct{}, len(ids))
for _, id := range ids {
set[id] = struct{}{}
}
return set, nil
}
t.Cleanup(func() { queueIDs = old })
return &calls
}
// A row stays "queued" forever when its delivery lines are gone for good. The
// queue is what settles it: a message Postfix still holds is simply in flight
// and must be left alone, while one it no longer holds will never be reported
// on and is closed.
func TestReconcileClosesOnlyWhatPostfixNoLongerHolds(t *testing.T) {
stubQueue(t, nil, "STILLQ")
cs := &captureStore{queued: []store.QueuedDelivery{
{QueueID: "STILLQ", To: "inflight@example.net"},
{QueueID: "GONEQ", To: "lost@example.net"},
}}
reconcile(cs, time.Now().UTC())
got := cs.snapshot()
if len(got) != 1 || got[0] != "GONEQ|lost@example.net|"+store.StatusBounced {
t.Fatalf("got %v, want only the message Postfix dropped closed as bounced", got)
}
}
// A queue that cannot be listed says nothing about any message — treating the
// failure as an empty queue would close every stale row at once.
func TestReconcileLeavesRowsAloneWhenTheQueueCannotBeRead(t *testing.T) {
stubQueue(t, errors.New("postqueue: Permission denied"))
cs := &captureStore{queued: []store.QueuedDelivery{
{QueueID: "GONEQ", To: "lost@example.net"},
}}
reconcile(cs, time.Now().UTC())
if got := cs.snapshot(); len(got) != 0 {
t.Fatalf("got %v, want no row touched", got)
}
}
// With nothing stale to explain there is no reason to shell out to postqueue at
// all — which is the normal state of a relay that is keeping up.
func TestReconcileSkipsTheQueueWhenNoRowIsStuck(t *testing.T) {
calls := stubQueue(t, nil)
reconcile(&captureStore{}, time.Now().UTC())
if *calls != 0 {
t.Fatalf("queue listed %d time(s), want none", *calls)
}
}
// A delivery's page shows what Postfix wrote about that one message, so the
// read has to pick its queue-id's lines out of everything else the mail path
// logged around them — and only its own: queue ids are hexadecimal runs, and a
// shorter one is regularly the tail of a longer one.
func TestQueueLinesPicksOutOneMessage(t *testing.T) {
path := writeLog(t,
"host postfix/smtpd[20]: 41E862C00D9E: client=mail.example.com[203.0.113.4]",
"host postfix/qmgr[10]: 5900C2C00D9E: from=<other@example.com>, size=500, nrcpt=1 (queue active)",
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
// Same run of characters, longer id: not this message.
"host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)",
"host opendkim[30]: 41E862C00D9E: DKIM-Signature field added (s=mail d=example.com)",
"host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
)
lines, err := QueueLines(path, "41E862C00D9E", 200)
if err != nil {
t.Fatalf("QueueLines: %v", err)
}
if len(lines) != 4 {
t.Fatalf("got %d lines, want 4:\n%s", len(lines), strings.Join(lines, "\n"))
}
// Oldest first: the page reads the message's history downwards.
if !strings.Contains(lines[0], "client=") || !strings.Contains(lines[3], "status=sent") {
t.Errorf("lines are not in the order they were logged:\n%s", strings.Join(lines, "\n"))
}
for _, line := range lines {
if strings.Contains(line, "FF41E862C00D9E") || strings.Contains(line, "5900C2C00D9E") {
t.Errorf("another message's line came back: %q", line)
}
}
}
// A message the milter refused has no queue id, so there is nothing to match
// on — every line in the log would be someone else's.
func TestQueueLinesWithoutAQueueIDMatchesNothing(t *testing.T) {
path := writeLog(t, "host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, status=sent (250 OK)")
lines, err := QueueLines(path, "", 200)
if err != nil {
t.Fatalf("QueueLines: %v", err)
}
if lines != nil {
t.Errorf("got %v, want no lines", lines)
}
}
// The cap keeps the newest lines, not the first ones: what a message did last
// is what its page is opened for.
func TestQueueLinesCapKeepsTheLatest(t *testing.T) {
var log []string
for i := 0; i < 10; i++ {
log = append(log, "host postfix/smtp[26]: ABC123: attempt "+itoa(i))
}
path := writeLog(t, log...)
lines, err := QueueLines(path, "ABC123", 3)
if err != nil {
t.Fatalf("QueueLines: %v", err)
}
if len(lines) != 3 || !strings.HasSuffix(lines[0], "attempt 7") || !strings.HasSuffix(lines[2], "attempt 9") {
t.Errorf("cap did not keep the last three:\n%s", strings.Join(lines, "\n"))
}
}
// Send-log rows outlive mail.log — retention is ninety days and rotation keeps
// fourteen files — so a message whose lines are gone, or a log that is between
// rotations and absent altogether, is an empty answer for the page to explain,
// not an error for it to report.
func TestQueueLinesOnAMessageWithNoLinesLeft(t *testing.T) {
path := writeLog(t, "host postfix/smtp[26]: 5900C2C00D9E: to=<z@example.net>, status=sent (250 OK)")
lines, err := QueueLines(path, "41E862C00D9E", 200)
if err != nil {
t.Fatalf("QueueLines: %v", err)
}
if lines != nil {
t.Errorf("got %v, want no lines", lines)
}
}
// writeLog creates a mail.log holding the given lines and returns its path.
func writeLog(t *testing.T, lines ...string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "mail.log")
body := ""
for _, line := range lines {
body += line + "\n"
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write log: %v", err)
}
return path
}
func itoa(n int) string { return strconv.Itoa(n) }
// The read is bounded, so on a log that has grown all day it starts in the
// middle of a line. That fragment is not a line and must not come back as one,
// and anything before the budget is out of reach — which the page reports the
// same way as a message whose lines have rotated away.
func TestQueueLinesReadsABoundedTail(t *testing.T) {
old := queueScanBytes
queueScanBytes = 120
t.Cleanup(func() { queueScanBytes = old })
path := writeLog(t,
"host postfix/smtp[26]: ABC123: too far back to reach, padded out past the budget with this run of filler text",
"host postfix/smtp[26]: ABC123: within the budget",
)
lines, err := QueueLines(path, "ABC123", 200)
if err != nil {
t.Fatalf("QueueLines: %v", err)
}
if len(lines) != 1 || !strings.HasSuffix(lines[0], "within the budget") {
t.Errorf("got %d lines, want only the one inside the budget:\n%s", len(lines), strings.Join(lines, "\n"))
}
}
// The delivery page shows a message's lines as a table of when and what, so the
// timestamp has to come off the head of the line — in either of the two formats
// a mail log arrives in — and nothing may be lost doing it.
func TestSplitTimestamp(t *testing.T) {
cases := []struct {
name string
line string
stamp, rest string
}{
{
name: "RFC 3339, which maillog_file_format selects on Postfix 3.9 and up",
line: "2026-08-03T05:15:52.219218+00:00 mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
stamp: "2026-08-03 05:15:52",
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
},
{
name: "no fractional seconds, zone as Z",
line: "2026-08-03T05:15:52Z mail postfix/qmgr[10]: 4A1B2C3D: removed",
stamp: "2026-08-03 05:15:52",
rest: "mail postfix/qmgr[10]: 4A1B2C3D: removed",
},
{
name: "no zone at all",
line: "2026-08-03T05:15:52 mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
stamp: "2026-08-03 05:15:52",
rest: "mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
},
{
name: "syslog's traditional format, space-padded day",
line: "Aug 3 05:15:52 mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
stamp: "Aug 3 05:15:52",
rest: "mail postfix/smtpd[20]: 4A1B2C3D: client=app.example.ru[203.0.113.4]",
},
{
// Copied off the live relay (Postfix 3.7, which has no
// maillog_file_format), so this is the shape the panel actually
// meets: zero-padded day, and the host is the container's name.
name: "syslog's traditional format as the live relay writes it",
line: "Aug 08 07:26:41 selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
stamp: "Aug 08 07:26:41",
rest: "selfpost postfix/master[231]: daemon started -- version 3.7.11, configuration /etc/postfix",
},
{
name: "unrecognised head keeps the whole line",
line: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
stamp: "",
rest: "mail postfix/smtp[26]: 4A1B2C3D: to=<a@example.net>, status=sent (250 OK)",
},
{
// A date-like run that is not the head of the line is not a stamp.
name: "date inside the text is left alone",
line: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
stamp: "",
rest: "mail postfix/smtp[26]: ABC: 220 mx.example.net ready at 2026-08-03T05:15:52+00:00",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
stamp, rest := SplitTimestamp(c.line)
if stamp != c.stamp || rest != c.rest {
t.Errorf("SplitTimestamp(%q) = (%q, %q), want (%q, %q)", c.line, stamp, rest, c.stamp, c.rest)
}
// Whatever the split, the line's own text survives it whole.
if !strings.Contains(c.line, rest) {
t.Errorf("the text column is not part of the line it came from: %q", rest)
}
})
}
}
+2 -2
View File
@@ -27,7 +27,7 @@ import (
// Store is the persistence the milter needs on the receive path: recording
// accepted messages (architecture.md § Mail path) and, for level-2 rate
// limiting (README § Rate limiting), looking up the configured limits and
// limiting (guide § Rate limiting), looking up the configured limits and
// counting recent messages. *store.Store satisfies it; tests substitute a
// fake.
type Store interface {
@@ -76,7 +76,7 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
// macros). This is also the earliest stage where both the sending domain (from
// the sender) and the application (the login) are known, so the level-2 rate
// limit is enforced here: over the limit, the message is refused with a 4xx
// tempfail before recipients are even offered (README § Rate limiting).
// tempfail before recipients are even offered (guide § Rate limiting).
// Enforcement is fail-open — see overLimit.
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
+2 -2
View File
@@ -8,7 +8,7 @@ import (
)
// overLimit reports whether the message currently being received should be
// refused under a level-2 differentiated limit (README § Rate limiting). It
// refused under a level-2 differentiated limit (guide § Rate limiting). It
// checks the domain-level and application-level limits in turn; either being
// exceeded is enough to refuse.
//
@@ -80,7 +80,7 @@ func (s *session) releaseReservations() {
}
// recordRejected writes a send-log row for a message refused by a level-2
// limit (README § Rate limiting — refusals are recorded too), so the rejection
// limit (guide § Rate limiting — refusals are recorded too), so the rejection
// shows up in the monitoring screen. Only MAIL-stage fields are known; the
// write is best-effort and never affects the response.
func (s *session) recordRejected() {
+41
View File
@@ -3,6 +3,7 @@ package postfix
import (
"fmt"
"os/exec"
"regexp"
"strings"
)
@@ -20,3 +21,43 @@ func Queue() (string, error) {
}
return string(out), nil
}
// QueueIDs returns the set of queue ids Postfix is still holding — everything
// in the maildrop, incoming, active, deferred and hold queues. It answers the
// one question the log-tailer's reconcile sweep asks about a send-log row stuck
// at "queued": is Postfix still working on this message, or has it left the
// queue without the panel ever seeing a delivery line for it (architecture.md §
// Log tailer)?
//
// An error means the queue could not be listed and therefore says nothing about
// any message; the caller must treat it as "no information", never as an empty
// queue.
func QueueIDs() (map[string]struct{}, error) {
out, err := Queue()
if err != nil {
return nil, err
}
return parseQueueIDs(out), nil
}
// queueEntryRe matches the first line of a `postqueue -p` entry, e.g.
//
// 3C5B04E6C1* 446 Thu Aug 7 10:12:31 app@example.com
//
// The id is at the start of the line, optionally flagged '*' (in the active
// queue) or '!' (on hold), and is followed by the message size. Requiring the
// size is what separates an entry from the listing's other left-margin lines:
// the '-Queue ID-' header, the '-- 5 Kbytes in 2 Requests.' trailer, a deferred
// entry's '(connect timed out)' reason, and 'Mail queue is empty'. Recipient
// lines are indented and never match.
var queueEntryRe = regexp.MustCompile(`^([0-9A-Za-z]+)[*!]?\s+\d+\s`)
func parseQueueIDs(listing string) map[string]struct{} {
ids := make(map[string]struct{})
for _, line := range strings.Split(listing, "\n") {
if m := queueEntryRe.FindStringSubmatch(line); m != nil {
ids[m[1]] = struct{}{}
}
}
return ids
}
+43
View File
@@ -0,0 +1,43 @@
package postfix
import "testing"
// The reconcile sweep decides whether a message is still Postfix's problem, so
// the parser must pick queue ids out of a real listing and nothing else out of
// it: not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line, which — unlike the recipient lines — starts at the left
// margin just as an entry does.
func TestParseQueueIDs(t *testing.T) {
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
rcpt@example.net
5B4A2C1D3E 446 Fri Aug 8 10:13:31 app@example.com
(connect to mx.example.net[203.0.113.9]:25: Connection timed out)
deferred@example.net
A1B2C3D4E5F! 891 Fri Aug 8 10:14:31 app@example.com
held@example.net
-- 1 Kbytes in 3 Requests.
`
ids := parseQueueIDs(listing)
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F"}
for _, id := range want {
if _, ok := ids[id]; !ok {
t.Errorf("queue id %s not found in %v", id, ids)
}
}
if len(ids) != len(want) {
t.Errorf("got %d ids %v, want exactly %v", len(ids), ids, want)
}
}
// An empty queue must come back as an empty set, not as a phantom id parsed out
// of Postfix's prose — every stale row would otherwise be compared against a
// listing that claims to hold a message called "Mail".
func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
if ids := parseQueueIDs("Mail queue is empty\n"); len(ids) != 0 {
t.Errorf("got %v, want no ids", ids)
}
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"time"
)
// Rate-limit scopes (README § Rate limiting). A level-2 limit is attached
// Rate-limit scopes (guide § Rate limiting). A level-2 limit is attached
// either to a domain (counted across all its applications and IPs) or to a
// single application.
const (
@@ -17,7 +17,7 @@ const (
RateLimitScopeApp = "application"
)
// RateLimit is a differentiated level-2 rate limit (README § Rate limiting):
// RateLimit is a differentiated level-2 rate limit (guide § Rate limiting):
// an optional set of expected client IPs plus a message ceiling over a sliding
// window, attached to a domain or an application. It is enforced in the
// journal-milter; level 1 (Postfix anvil, architecture.md § Mail path) is the
@@ -27,7 +27,7 @@ const (
// Both the IP binding and the ceiling are optional in the schema, but a limit
// is only enforced when it is Active(): the design deliberately allows an
// admin to leave the IP binding empty for apps that send from changing IPs, in
// which case only level 1 protects them (README § Rate limiting).
// which case only level 1 protects them (guide § Rate limiting).
type RateLimit struct {
Scope string
RefID int64
@@ -38,7 +38,7 @@ type RateLimit struct {
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit
// inert (README § Rate limiting): the IP binding is what scopes the limit to a
// inert (guide § Rate limiting): the IP binding is what scopes the limit to a
// known sender.
func (r RateLimit) Active() bool {
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
@@ -153,7 +153,7 @@ func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
// CountMessages returns how many distinct messages the reference (a domain
// name or an application login) has queued since t, for the level-2 sliding
// window (README § Rate limiting). It counts distinct queue-ids — one message
// window (guide § Rate limiting). It counts distinct queue-ids — one message
// with many recipients is one message, matching level 1's per-message
// semantics — and excludes rows that were themselves rejected by a limit (they
// were never sent). It reuses the send log the journal already writes (README
+41 -2
View File
@@ -17,7 +17,7 @@ const (
StatusDeferred = "deferred"
StatusBounced = "bounced"
// StatusRejected marks a message the journal-milter refused with a 4xx under
// a level-2 rate limit (README § Rate limiting). Such a row never gets a
// a level-2 rate limit (guide § Rate limiting). Such a row never gets a
// queue-id and is excluded from the level-2 message count (it was never
// sent).
StatusRejected = "rejected"
@@ -55,7 +55,7 @@ func (s *Store) InsertQueued(e SendLogEntry) error {
}
// InsertRejected records a message the journal-milter refused under a level-2
// rate limit (README § Rate limiting), so the rejection is visible in the
// rate limit (guide § Rate limiting), so the rejection is visible in the
// send-log UI. Only the fields known at MAIL FROM are set (domain, sender, app
// login); there is no queue-id or recipient because the message was rejected
// before it was queued.
@@ -93,6 +93,45 @@ func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
return n, nil
}
// QueuedDelivery is a send-log row still waiting for a delivery result,
// reduced to what the log-tailer's reconcile sweep needs to look it up in the
// Postfix queue and, failing that, to close it (architecture.md § Log tailer).
type QueuedDelivery struct {
QueueID string
To string
}
// ListQueuedOlderThan returns the rows still marked "queued" that were accepted
// before cutoff — old enough that Postfix should long since have reported a
// result for them. Rows without a queue-id are skipped: the milter refused
// those before Postfix ever saw the message, so the queue has nothing to say
// about them.
//
// created_at is stored as RFC3339 UTC, so a lexical comparison against the same
// format is chronologically correct.
func (s *Store) ListQueuedOlderThan(cutoff time.Time) ([]QueuedDelivery, error) {
rows, err := s.db.Query(
`SELECT queue_id, to_addr FROM send_log
WHERE status = ? AND queue_id <> '' AND created_at < ?
ORDER BY id`,
StatusQueued, cutoff.UTC().Format(time.RFC3339),
)
if err != nil {
return nil, fmt.Errorf("list queued send_log rows: %w", err)
}
defer rows.Close()
var out []QueuedDelivery
for rows.Next() {
var d QueuedDelivery
if err := rows.Scan(&d.QueueID, &d.To); err != nil {
return nil, fmt.Errorf("scan queued send_log row: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// SendLogRow is one row as returned to the monitoring UI (architecture.md §
// Persistence): a SendLogEntry plus the fields that only exist once a row has
// been written (id, current status, timestamps).
+52
View File
@@ -95,6 +95,58 @@ func TestUpdateStatusRecipientCaseInsensitive(t *testing.T) {
}
}
// The reconcile sweep asks for the rows old enough that Postfix should have
// reported on them by now. A message accepted moments ago is simply in flight,
// and one the milter refused never reached the queue at all, so neither is the
// sweep's business.
func TestListQueuedOlderThan(t *testing.T) {
st := openTestStore(t)
for _, e := range []SendLogEntry{
{QueueID: "OLD1", To: "stale@example.net"},
{QueueID: "NEW1", To: "fresh@example.net"},
} {
if err := st.InsertQueued(e); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
// A row the milter refused: no queue-id, and a status the sweep never sees.
if err := st.InsertRejected(SendLogEntry{To: "refused@example.net"}); err != nil {
t.Fatalf("InsertRejected: %v", err)
}
// A row that has already been delivered, aged the same as the stale one.
if err := st.InsertQueued(SendLogEntry{QueueID: "DONE1", To: "done@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
if _, err := st.UpdateStatus("DONE1", "done@example.net", StatusSent); err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
cutoff := time.Now().UTC().Add(-2 * time.Minute)
backdate(t, st, "OLD1", cutoff.Add(-time.Hour))
backdate(t, st, "DONE1", cutoff.Add(-time.Hour))
got, err := st.ListQueuedOlderThan(cutoff)
if err != nil {
t.Fatalf("ListQueuedOlderThan: %v", err)
}
if len(got) != 1 || got[0] != (QueuedDelivery{QueueID: "OLD1", To: "stale@example.net"}) {
t.Fatalf("got %+v, want only the stale queued row", got)
}
}
// backdate rewrites a row's acceptance time, so a test can age it past a cutoff
// without waiting.
func backdate(t *testing.T, s *Store, queueID string, at time.Time) {
t.Helper()
if _, err := s.db.Exec(
`UPDATE send_log SET created_at = ? WHERE queue_id = ?`,
at.UTC().Format(time.RFC3339), queueID,
); err != nil {
t.Fatalf("backdate %s: %v", queueID, err)
}
}
func TestUpdateStatusNoMatch(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
+1 -1
View File
@@ -30,7 +30,7 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
// field after a rejected submission; the password fields are never repopulated.
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
s.render(w, status, "account", map[string]any{
"Title": "SelfPost — account",
"Title": "SelfPost — settings",
"User": currentUser(r),
"Active": "account",
"FormUsername": formUsername,
+1 -1
View File
@@ -30,7 +30,7 @@ type detailView struct {
FormAddrs string
NewCred *newCred
// RateLimitErr surfaces a validation error from a domain- or
// application-level rate-limit form (README § Rate limiting) as a page
// application-level rate-limit form (guide § Rate limiting) as a page
// banner.
RateLimitErr string
// ExportErr surfaces a rejected encryption password from the export card.
+163 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"time"
"github.com/mixeme/selfpost/internal/logtail"
"github.com/mixeme/selfpost/internal/mailhdr"
@@ -15,10 +16,12 @@ import (
// sendLogPageSize bounds each send-log page (product.md's monitoring screens
// call for pagination); logTailLines bounds how much of mail.log the log view
// shows per refresh.
// shows per refresh, and deliveryLogLines how many of one message's own lines
// its page shows.
const (
sendLogPageSize = 50
logTailLines = 200
sendLogPageSize = 50
logTailLines = 200
deliveryLogLines = 200
)
// handleDeliveries renders the Deliveries page over the send log: server-side
@@ -57,6 +60,13 @@ func (s *Server) handleDeliveriesRows(w http.ResponseWriter, r *http.Request) {
// how it ended — and every remaining field (domain, application, queue id, when
// the status was last reported) lives here, one page per row, so widening the
// journal never costs the table a column.
//
// The page answers the question the log raises rather than restating it: what
// the journal recorded, in what order it happened, and what Postfix itself
// wrote about the message. So it is three blocks — the message's own facts and
// its history side by side, and the mail.log lines for its queue id under both.
// The queue id used to be printed here as something to go and search the system
// log for by hand; the search is done for the operator instead.
func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
@@ -75,17 +85,167 @@ func (s *Server) handleDelivery(w http.ResponseWriter, r *http.Request) {
return
}
row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := s.deliveryLog(row)
s.render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery",
"User": currentUser(r),
"Active": "deliveries",
"Row": row,
// The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel.
"Level": deliveryLevel(row.Status),
"Events": deliveryEvents(row),
// The mail.log lines for this message, and — when there are none — the
// reason, which is a normal outcome rather than a failure.
"LogRows": logRows,
"LogNote": logNote,
// Where the row came from, so "Back" returns to the page and filters
// the operator was looking at rather than the top of an unfiltered log.
"BackURL": deliveriesBackURL(r),
})
}
// deliveryLevel maps a send-log status onto the ok/warn/error/unknown badge
// vocabulary the status page and the DNS checks already use (see .st in
// panel.css), so a colour means the same thing on every page: delivered is the
// good outcome, deferred is not settled yet, and the two refusals are failures.
// A queued row is "unknown" rather than "warn" — nothing has gone wrong, it is
// simply that nothing has been reported.
func deliveryLevel(status string) string {
switch status {
case store.StatusSent:
return "ok"
case store.StatusDeferred:
return "warn"
case store.StatusBounced, store.StatusRejected:
return "error"
default:
return "unknown"
}
}
// deliveryEvent is one step of a message's history, as the timeline on the
// delivery page draws it. At is zero for the step that has not happened yet —
// the delivery report a queued message is still waiting for.
type deliveryEvent struct {
At time.Time
Level string // ok / warn / error / unknown, as deliveryLevel returns
Status string // the send-log status value this step reached
Title string
Detail string
}
// deliveryEvents turns a row's two timestamps into the history the page shows.
// The journal keeps no event table — a row is created when the message is
// accepted and updated once when Postfix reports the attempt — so the two
// timestamps *are* the history, and stating them as steps is what makes a row
// whose created_at and updated_at differ by six hours legible as "queued for
// six hours, then delivered" rather than as two dates in a list of fields.
func deliveryEvents(row store.SendLogRow) []deliveryEvent {
// A rejected message has no second step, and its first one is not an
// acceptance: the journal-milter refused it, so Postfix never queued it.
if row.Status == store.StatusRejected {
return []deliveryEvent{{
At: row.CreatedAt,
Level: "error",
Status: store.StatusRejected,
Title: "Refused before queueing",
Detail: "The journal-milter refused the message under a rate limit. It was never queued, so there is no queue id and Postfix never attempted delivery.",
}}
}
events := []deliveryEvent{{
At: row.CreatedAt,
Level: "unknown",
Status: store.StatusQueued,
Title: "Accepted and queued",
Detail: "Postfix accepted the message over an authenticated submission and the journal-milter recorded it. Delivery to the recipient had not been attempted yet.",
}}
switch row.Status {
case store.StatusQueued:
// The step that has not happened. Drawn as an open dot with no time.
return append(events, deliveryEvent{
Level: "unknown",
Status: store.StatusQueued,
Title: "Waiting for a delivery report",
Detail: "Postfix has not reported an attempt for this recipient yet. The Mail queue page shows what it is still holding.",
})
case store.StatusSent:
return append(events, deliveryEvent{
At: row.UpdatedAt,
Level: "ok",
Status: store.StatusSent,
Title: "Delivered",
Detail: "The receiving server accepted the message. That is as far as this server can see — what the recipient's mailbox then did with it is not reported back.",
})
case store.StatusDeferred:
return append(events, deliveryEvent{
At: row.UpdatedAt,
Level: "warn",
Status: store.StatusDeferred,
Title: "Deferred, will be retried",
Detail: "The receiving server could not take the message yet. Postfix keeps it queued and retries until it is delivered or the queue lifetime runs out.",
})
case store.StatusBounced:
return append(events, deliveryEvent{
At: row.UpdatedAt,
Level: "error",
Status: store.StatusBounced,
Title: "Bounced",
Detail: "Delivery failed for good: the receiving server refused the message permanently, or Postfix gave up after the queue lifetime. The reason is in the delivery log below.",
})
default:
// A status the log-tailer learns to write before this switch does.
return append(events, deliveryEvent{
At: row.UpdatedAt,
Level: deliveryLevel(row.Status),
Status: row.Status,
Title: "Status reported",
Detail: "The last state Postfix reported for this recipient.",
})
}
}
// deliveryLogRow is one mail.log line split for the table on the delivery
// page: when it was written, and what it says. Time is empty for a line whose
// head is not a timestamp the log format recognises — the line still shows, in
// full, under Message.
type deliveryLogRow struct {
Time string
Text string
}
// deliveryLog reads the mail.log lines Postfix wrote about one message and
// splits each into the two columns the page shows it in. The second return
// value is what to say when there are none: every reason for an empty result
// here is an ordinary one — the message never reached the queue, or its lines
// have aged out of the log — so none of them is an error on the page. Only a
// log that cannot be read at all is reported as a fault, and that one is
// logged for the operator as well.
func (s *Server) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string) {
if row.QueueID == "" {
return nil, "This message never reached the queue, so Postfix wrote no delivery lines for it."
}
lines, err := logtail.QueueLines(s.cfg.MailLogPath, row.QueueID, deliveryLogLines)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
logf("panel: delivery log %s: %v", row.QueueID, err)
return nil, "Could not read the mail log."
}
if len(lines) == 0 {
// Send-log rows outlive mail.log: retention is ninety days by default
// and rotation keeps fourteen files, so an older message having nothing
// left to show is the normal end state, not a fault.
return nil, "Nothing for this queue id in the current mail log. Its lines have most likely been rotated away."
}
out := make([]deliveryLogRow, len(lines))
for i, line := range lines {
stamp, rest := logtail.SplitTimestamp(line)
out[i] = deliveryLogRow{Time: stamp, Text: rest}
}
return out, ""
}
// deliveriesBackURL rebuilds the delivery-log URL a detail page was opened
// from. Only the log's own parameters are carried over, and each is re-encoded
// by url.Values, so nothing a visitor appends to the link can travel back into
+119
View File
@@ -3,6 +3,7 @@ package web
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
@@ -90,6 +91,113 @@ func TestDeliveryPageShowsWhatTheLogOmits(t *testing.T) {
}
}
// The page's second column is the message's history: the two timestamps the
// journal holds, stated as the steps they stand for, so a row is readable as
// what happened to the message rather than as a list of fields.
func TestDeliveryPageTellsTheMessagesHistory(t *testing.T) {
s, row := serverWithDelivery(t)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"Accepted and queued", "Delivered",
row.CreatedAt.Format("2006-01-02 15:04:05"),
row.UpdatedAt.Format("2006-01-02 15:04:05"),
// A delivered message is "ok" in the panel's own badge vocabulary, the
// same one the status page and the DNS checks use.
`class="st st-ok"`,
} {
if !strings.Contains(out, want) {
t.Errorf("delivery page is missing %q:\n%s", want, out)
}
}
// Accepted comes before delivered: a history read in the wrong order is
// worse than none.
if strings.Index(out, "Accepted and queued") > strings.Index(out, "Delivered") {
t.Errorf("the history is not in the order it happened:\n%s", out)
}
}
// A queued message has no second timestamp to state, so the step it is waiting
// for is drawn as one that has not happened rather than dated with the moment
// the row was written.
func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
s, _ := serverWithDelivery(t)
if err := s.store.InsertQueued(store.SendLogEntry{
QueueID: "7F7F7F7F", Domain: "bs.example.ru", AppLogin: "Queuer3C",
From: "noreply@bs.example.ru", To: "waiting@example.ru", Subject: "Still going",
}); err != nil {
t.Fatalf("insert: %v", err)
}
rows, err := s.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(rows[0].ID))
for _, want := range []string{"Waiting for a delivery report", "pending", "not yet"} {
if !strings.Contains(out, want) {
t.Errorf("delivery page does not mark the message as still waiting (%q):\n%s", want, out)
}
}
}
// The queue id used to be printed as something to go and search the system log
// for by hand; the page does that search now, and shows only this message's
// lines — as a table of when and what, so the seconds between the connection
// and the reply line up down one edge.
func TestDeliveryPageShowsThisMessagesLogLines(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t,
"2026-08-03T05:15:52.219218+00:00 host postfix/smtpd[20]: 4A1B2C3D: client=mail.example.com[203.0.113.4]",
"2026-08-03T05:15:52.300000+00:00 host postfix/qmgr[10]: 99999999: from=<other@example.ru>, size=500, nrcpt=1 (queue active)",
"2026-08-03T05:16:03.884210+00:00 host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, dsn=2.0.0, status=sent (250 OK)",
)
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"<th>Time</th>", "<th>Message</th>",
// The stamp is split off into its own cell, without the microseconds
// and the offset that make it the widest thing on the line.
`<td class="time muted">2026-08-03 05:15:52</td>`,
`<td class="time muted">2026-08-03 05:16:03</td>`,
"client=mail.example.com", "status=sent (250 OK)",
} {
if !strings.Contains(out, want) {
t.Errorf("delivery log table is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "99999999") {
t.Errorf("delivery page shows another message's log line:\n%s", out)
}
}
// A line whose head is not a timestamp still has to show in full; the format is
// the log's, not ours, and a line we cannot split is a line we must not drop.
func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = writeMailLog(t, "host postfix/smtp[26]: 4A1B2C3D: to=<public@example.ru>, status=sent (250 OK)")
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "host postfix/smtp[26]: 4A1B2C3D: to=&lt;public@example.ru&gt;, status=sent (250 OK)") {
t.Errorf("an unstamped log line did not survive the split into columns:\n%s", out)
}
}
// Rows outlive mail.log, and a message the milter refused never reached the
// queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
s, row := serverWithDelivery(t)
s.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
out := getBody(t, s.handleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
}
if strings.Contains(out, `class="error"`) || strings.Contains(out, "Could not read the mail log") {
t.Errorf("an aged-out delivery log is reported as a failure:\n%s", out)
}
}
// Send-log rows are pruned on the retention window, so a bookmarked delivery
// that no longer exists is a 404, not a 500.
func TestDeliveryPageNotFound(t *testing.T) {
@@ -159,3 +267,14 @@ func getBody(t *testing.T, h http.HandlerFunc, target string) string {
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
// writeMailLog creates a mail.log holding the given lines and returns its path,
// for the pages that read the log rather than the journal.
func writeMailLog(t *testing.T, lines ...string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "mail.log")
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatalf("write mail.log: %v", err)
}
return path
}
+6 -6
View File
@@ -11,13 +11,13 @@ import (
)
// defaultRateLimitWindowSeconds is the sliding-window length used when an
// admin sets a message ceiling but leaves the window blank (README § Rate
// limiting, matching the level-1 default hour; README § Environment variables:
// admin sets a message ceiling but leaves the window blank (guide § Rate
// limiting, matching the level-1 default hour; guide § Environment variables:
// RATE_LIMIT_WINDOW_SECONDS).
const defaultRateLimitWindowSeconds = 3600
// rateLimitInput is the validated result of a rate-limit form submission.
// clear means "remove the differentiated limit" (README § Rate limiting: an
// clear means "remove the differentiated limit" (guide § Rate limiting: an
// empty IP binding leaves only level 1).
type rateLimitInput struct {
clear bool
@@ -42,7 +42,7 @@ func parseRateLimitForm(r *http.Request) (rateLimitInput, error) {
return rateLimitInput{}, err
}
if len(ips) == 0 {
// No IP binding: the differentiated limit does not apply (README § Rate
// No IP binding: the differentiated limit does not apply (guide § Rate
// limiting).
return rateLimitInput{clear: true}, nil
}
@@ -92,7 +92,7 @@ func parsePositiveInt(raw string, def int) (int, error) {
}
// handleDomainRateLimit saves or clears a domain-level differentiated rate
// limit (README § Rate limiting). No reload is needed — the milter reads the
// limit (guide § Rate limiting). No reload is needed — the milter reads the
// row live.
func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
d, ok := s.lookupDomain(w, r)
@@ -116,7 +116,7 @@ func (s *Server) handleDomainRateLimit(w http.ResponseWriter, r *http.Request) {
}
// handleAppRateLimit saves or clears an application-level differentiated rate
// limit (README § Rate limiting).
// limit (guide § Rate limiting).
func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) {
a, ok := s.lookupApplication(w, r)
if !ok {
+1 -1
View File
@@ -17,7 +17,7 @@ const usernameKey ctxKey = 0
// everything except a GET request carrying HX-Request: the four monitoring
// fragments (/status/fragment, /mail-queue/body, /system-log/body,
// /deliveries/rows)
// poll every 5s regardless of whether anyone is looking at the tab, so
// poll on a timer regardless of whether anyone is looking at the tab, so
// counting those as activity would make "N days idle" mean "N days since a
// browser tab was last open" instead.
func (s *Server) requireAuth(next http.Handler) http.Handler {
+59
View File
@@ -0,0 +1,59 @@
package web
import (
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"path"
)
// staticETags maps each /static/ URL path to a strong ETag over the asset's
// bytes, computed once at startup from the embedded FS.
//
// The assets are baked into the binary, so their FS modification times are the
// zero value and http.FileServer sends no Last-Modified. Without an ETag either,
// a response carries no validator at all and the browser is free to guess how
// long to keep it — which is how a replaced favicon keeps showing the old mark
// long after a deploy. Hashing the content gives every asset a validator that
// changes exactly when the asset does.
var staticETags = buildStaticETags()
func buildStaticETags() map[string]string {
etags := make(map[string]string)
// An error here would mean the embed directive and this walk disagree, which
// is a build-time defect rather than a runtime condition; the assets still
// serve correctly without a validator, so skip what can't be read.
_ = fs.WalkDir(assetsFS, "static", func(name string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
b, err := fs.ReadFile(assetsFS, name)
if err != nil {
return nil
}
sum := sha256.Sum256(b)
etags["/"+name] = `"` + hex.EncodeToString(sum[:16]) + `"`
return nil
})
return etags
}
// staticHandler serves the embedded assets under /static/ with a content ETag.
//
// Cache-Control is no-cache rather than a max-age: it lets the browser keep the
// copy but requires it to revalidate, so an asset that changed is picked up on
// the next page load while an unchanged one costs a 304 with no body. For a
// handful of small files on a single-operator panel that trade is the right way
// round — correctness after a deploy matters more than saving the round trip.
// http.ServeContent answers the conditional request from the ETag we set here.
func staticHandler() http.Handler {
files := http.FileServer(http.FS(assetsFS))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if etag, ok := staticETags[path.Clean(r.URL.Path)]; ok {
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
})
}
+100 -6
View File
@@ -81,12 +81,14 @@ main { flex: 1 1 auto; min-width: 0; width: 100%; max-width: 64rem; }
floating in the middle — the misalignment that gave login and setup their own
narrow column (main.page-login further down). */
main > * { max-width: 48rem; margin-left: auto; margin-right: auto; }
/* The three monitoring pages are the ones that opt out: six columns of send
log, and raw mail.log lines that are long by nature, which at the measure
left Subject and Status fighting over the last inch and wrapped every second
log line. The class is declared by the page itself (the "wide" block in
layout.html) rather than derived from the page name, since a delivery's own
page is prose and shares the send log's name in the navigation. */
/* The four monitoring pages are the ones that opt out: six columns of send
log, raw mail.log lines that are long by nature — which at the measure left
Subject and Status fighting over the last inch and wrapped every second log
line — and a single delivery's page, which stands two cards side by side over
a block of those same log lines. The class is declared by the page itself
(the "wide" block in layout.html) rather than derived from the page name: a
page's name is the navigation entry it belongs under, which several pages
share, and how wide a page needs to be is a property of that page alone. */
main.wide > * { max-width: none; }
/* Longhands, not the margin shorthand: the shorthand would re-zero the auto
side margins above and pin the heading to the column's edge, a measure's
@@ -390,6 +392,98 @@ meter { width: 5rem; height: 0.7rem; vertical-align: middle; margin-right: 0.4re
.metric { white-space: nowrap; }
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
/* Two cards abreast, on the one page that has a pair of them worth reading
together (a delivery's facts beside its history). auto-fit with a minimum
rather than a media query: the pair falls into one column exactly when a
column would drop below the width its content needs, which is also what
happens inside the narrow column the navigation lies down at. */
.split {
display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
gap: 1.2rem; margin-top: 1.2rem;
}
/* The gap already spaces the cards inside the grid; the general .card + .card
rule would add a second gap's worth on top of it, and only to the second
card, which in one column reads as an uneven stack. The blocks around the
grid keep their spacing from it instead. */
.split > .card + .card { margin-top: 0; }
.split + .card { margin-top: 1.2rem; }
/* The subject heads a delivery's page, and it is the one heading in the panel
whose text we do not control: it may be a hundred characters with nothing to
break on. It wraps to as many lines as it needs (this is the page's name, not
a table cell to clip) but never widens the column. */
h1.subject { overflow-wrap: anywhere; }
/* The line under it: sender, recipient and outcome. Wrapping rather than
clipping, since either address may be long and both are the point. The
vertical margins are longhands for the reason given at the h1 rule above —
the shorthand would re-zero the auto side margins that keep a page's blocks
centred on the measure. It sits close under the heading: the two are one
statement of what this page is about. */
.route {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem;
margin-top: -0.5rem; margin-bottom: 1rem;
}
.route .addr { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9rem; overflow-wrap: anywhere; }
.route .arrow { color: #6b7280; }
/* A card's short facts, as a grid of tiles rather than the label-over-value
stack the rest of the panel uses. That stack is right for the pages where a
value is a DNS record or a password to copy — one per line, full width — and
wrong for six values of a few characters each, which down a narrow column
would be a page of mostly empty rows. Same auto-fit as .split: as many
columns as fit, and one when nothing else does. */
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr)); gap: 0.5rem; margin-top: 1rem; }
.fact { min-width: 0; padding: 0.5rem 0.7rem; border-radius: 8px; background: var(--surface-bg); }
.fact-label {
display: block; font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #6b7280;
}
/* Nothing here is guaranteed to have a space in it — domains, logins and queue
ids are all one run — so a value may break mid word rather than push its tile
wider than the column it shares. */
.fact-value { display: block; margin-top: 0.1rem; overflow-wrap: anywhere; }
.fact-value.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
/* A message's history: the steps down a line, each with the time it happened,
the status it reached and what that means. The line is the list's own left
border and the dots sit on top of it, so nothing has to be positioned against
a fixed row height — a step's text is two lines or five depending on what
there is to say about it. */
.timeline { list-style: none; display: flex; flex-direction: column; gap: 1.1rem;
margin: 1rem 0 0; padding: 0.2rem 0 0.2rem 1.4rem; border-left: 2px solid var(--border); }
.event { position: relative; min-width: 0; }
.event::before {
content: ""; position: absolute; left: -1.85rem; top: 0.3rem;
width: 0.65rem; height: 0.65rem; border-radius: 50%;
background: var(--card-bg); border: 2px solid var(--control-border);
}
/* The dot carries the step's own verdict, in the same four colours as the badge
beside it — so the shape of the history is readable down the line alone. */
.event.lvl-ok::before { border-color: var(--st-ok-fg); background: var(--st-ok-bg); }
.event.lvl-warn::before { border-color: var(--st-warn-fg); background: var(--st-warn-bg); }
.event.lvl-error::before { border-color: var(--st-error-fg); background: var(--st-error-bg); }
/* The step that has not happened yet — a queued message's delivery report — is
the one hollow dot, and its text is quiet: it is what to expect, not a record
of anything. */
.event.pending { opacity: 0.7; }
.event.pending::before { background: var(--card-bg); border-style: dashed; }
.event-time { margin: 0; font-size: 0.75rem; color: #6b7280; }
.event-title { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin: 0.15rem 0 0; font-weight: 600; }
.event-detail { margin: 0.2rem 0 0; font-size: 0.9rem; }
/* A message's own mail.log lines, as a table of when and what. The time column
is sized to its content — width:1% is as narrow as the automatic layout will
take a column, which with the nowrap on td.time settles on exactly the stamp
— so everything left over goes to the text, which is the column that needs
it. */
table.log { margin-top: 1rem; }
table.log th:first-child, table.log td.time { width: 1%; }
/* The log's own text: monospace, because it is full of addresses, response
codes and identifiers that are compared character by character rather than
read as words. pre-wrap keeps the spacing a server's reply was written with
while still letting a long line wrap inside the cell instead of widening it
(the th/td rule above allows the break). */
table.log td.log-text {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem; white-space: pre-wrap;
}
/* Compact outlined button: same affordance as the filled one but quiet enough
that several can sit together without shouting — the Copy buttons beside a
value, the controls of a table row or of an application block. Sign out
+88 -12
View File
@@ -144,7 +144,7 @@
//
// Each pass looks its targets up by id rather than holding on to elements
// found once: the status page replaces its cards wholesale every five
// seconds (hx-swap on #status-body), and anything remembered here would be
// seconds (adaptive polling on #status-body), and anything remembered here would be
// measuring boxes that had left the document.
var sectionLinks = [];
@@ -204,19 +204,95 @@
initSectionIndex();
});
// --- Skip polling while the tab is hidden ------------------------------
// The monitoring pages (status, mail queue, system log, deliveries) poll
// every 5s via hx-trigger="every 5s". htmx has a built-in way to make that
// conditional (an event filter, hx-trigger="every 5s [expr]"), but it
// evaluates the filter with `new Function`, which the panel's CSP
// (default-src 'self', no 'unsafe-eval') would silently break. Skipping the
// request here instead needs nothing beyond what the CSP already allows: a
// request due while the tab is hidden is simply not sent, and the next
// request after it becomes visible again picks up on schedule as usual.
// --- Adaptive monitoring polling ---------------------------------------
// The four monitoring fragments carry data-poll and hx-trigger="load" for
// the first fetch only. panel.js schedules the rest: 5 s while the operator
// is active on the page, 30 s when the tab is visible but idle, and nothing
// while the tab is hidden. hx-trigger="every Ns [expr]" could express some
// of that, but the filter is evaluated with `new Function`, which the
// panel's CSP (default-src 'self', no 'unsafe-eval') would silently break.
var pollActiveMs = 5000;
var pollIdleMs = 30000;
// No pointer/keyboard/scroll input for this long → treat the tab as idle.
var userIdleMs = 30000;
var lastActivity = Date.now();
var pollTimers = Object.create(null);
["mousedown", "mousemove", "keydown", "scroll", "touchstart"].forEach(function (evt) {
document.addEventListener(evt, function () {
lastActivity = Date.now();
}, { passive: true });
});
function pollDelayMs() {
return Date.now() - lastActivity < userIdleMs ? pollActiveMs : pollIdleMs;
}
function triggerPoll(el) {
htmx.ajax("GET", el.getAttribute("hx-get"), {
target: "#" + el.id,
swap: el.getAttribute("hx-swap") || "outerHTML"
});
}
function schedulePoll(el) {
if (!el || !el.id || !el.hasAttribute("data-poll")) {
return;
}
if (pollTimers[el.id]) {
clearTimeout(pollTimers[el.id]);
delete pollTimers[el.id];
}
if (document.hidden) {
return;
}
var id = el.id;
pollTimers[id] = setTimeout(function () {
delete pollTimers[id];
var current = document.getElementById(id);
if (!current || !current.hasAttribute("data-poll")) {
return;
}
if (document.hidden) {
schedulePoll(current);
return;
}
triggerPoll(current);
}, pollDelayMs());
}
function onPollElementReady(el) {
if (!el || !el.hasAttribute("data-poll")) {
return;
}
// Swapped-in markup still carries hx-trigger="load"; strip it so htmx does
// not issue a duplicate GET on top of the response we just received.
el.removeAttribute("hx-trigger");
schedulePoll(el);
}
document.body.addEventListener("htmx:afterSwap", function (ev) {
onPollElementReady(ev.detail.elt);
});
document.body.addEventListener("htmx:responseError", function (ev) {
onPollElementReady(ev.detail.elt);
});
document.body.addEventListener("htmx:beforeRequest", function (ev) {
var trigger = ev.target.getAttribute && ev.target.getAttribute("hx-trigger");
if (document.hidden && trigger && trigger.indexOf("every") !== -1) {
if (document.hidden && ev.target.hasAttribute && ev.target.hasAttribute("data-poll")) {
ev.preventDefault();
}
});
document.addEventListener("visibilitychange", function () {
if (document.hidden) {
Object.keys(pollTimers).forEach(function (id) {
clearTimeout(pollTimers[id]);
delete pollTimers[id];
});
return;
}
document.querySelectorAll("[data-poll]").forEach(schedulePoll);
});
})();
+74
View File
@@ -0,0 +1,74 @@
package web
import (
"net/http"
"net/http/httptest"
"testing"
)
// serveStatic runs one GET against the static handler.
func serveStatic(path string, headers map[string]string) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodGet, path, nil)
for k, v := range headers {
r.Header.Set(k, v)
}
rec := httptest.NewRecorder()
staticHandler().ServeHTTP(rec, r)
return rec
}
// Every embedded asset must carry a validator. The favicon is the one that
// prompted this: a browser given no ETag and no Last-Modified caches it on a
// guess, and a rebranded panel keeps serving the old mark from the tab.
func TestStaticAssetsCarryETag(t *testing.T) {
for _, name := range []string{"favicon.png", "favicon.svg", "panel.css", "panel.js", "htmx.min.js"} {
rec := serveStatic("/static/"+name, nil)
if rec.Code != http.StatusOK {
t.Fatalf("%s: got status %d, want 200", name, rec.Code)
}
if rec.Header().Get("ETag") == "" {
t.Errorf("%s: no ETag", name)
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
t.Errorf("%s: Cache-Control = %q, want %q", name, got, "no-cache")
}
}
}
// The point of the ETag is the cheap second request: the browser sends back
// what it has and gets a bodyless 304 when nothing changed.
func TestStaticETagRevalidates(t *testing.T) {
first := serveStatic("/static/favicon.png", nil)
etag := first.Header().Get("ETag")
if etag == "" {
t.Fatal("no ETag on the first response")
}
same := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": etag})
if same.Code != http.StatusNotModified {
t.Errorf("matching If-None-Match: got status %d, want 304", same.Code)
}
if same.Body.Len() != 0 {
t.Errorf("matching If-None-Match: got %d bytes of body, want none", same.Body.Len())
}
// A stale validator — what a browser holds after the asset is replaced —
// has to produce the new bytes rather than another 304.
stale := serveStatic("/static/favicon.png", map[string]string{"If-None-Match": `"0000000000000000"`})
if stale.Code != http.StatusOK {
t.Errorf("stale If-None-Match: got status %d, want 200", stale.Code)
}
if stale.Body.Len() == 0 {
t.Error("stale If-None-Match: empty body, want the asset")
}
}
// Two different assets must not share a validator, or replacing one would be
// masked by the other's cached copy.
func TestStaticETagsAreContentDerived(t *testing.T) {
png := serveStatic("/static/favicon.png", nil).Header().Get("ETag")
svg := serveStatic("/static/favicon.svg", nil).Header().Get("ETag")
if png == svg {
t.Errorf("favicon.png and favicon.svg share the ETag %s", png)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "content"}}
<h1>Account</h1>
<h1>Settings</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
+6 -4
View File
@@ -11,7 +11,8 @@
<p class="muted"><strong>The backup file is a secret</strong> (it contains
private keys and credentials). Store and transfer it securely and delete it
once the restore succeeds. Encrypting it below is the simplest way to do that:
the download is then a <code>.spbk</code> file that only the password opens.</p>
the download is then a <code>.spbk</code> file (SelfPost backup) that only
the password opens.</p>
{{if .BackupErr}}<p class="error">{{.BackupErr}}</p>{{end}}
<form method="post" action="/backup">
{{template "encryptfields" .}}
@@ -22,9 +23,10 @@
<div class="card">
<h2>Import a domain</h2>
<p class="muted">Move a single domain here from another SelfPost instance using
a domain export file (from that domain's page). Its DKIM key and application
passwords come across, so the published DNS record needs no change. The export
file is a secret, like a full backup.</p>
a domain export file (from that domain's page) — plain <code>.json</code> or
encrypted <code>.spde</code> (SelfPost domain export). Its DKIM key and
application passwords come across, so the published DNS record needs no
change. The export file is a secret, like a full backup.</p>
{{if .ImportErr}}<p class="error">{{.ImportErr}}</p>{{end}}
<form method="post" action="/domains/import" enctype="multipart/form-data">
<label for="importfile">Domain export file</label>
+3 -2
View File
@@ -1,7 +1,8 @@
{{/* The send log is six columns of addresses, subjects and timestamps, so this
page takes the whole column rather than the reading measure (see the "wide"
block in layout.html). A single delivery's page carries prose and keeps the
measure, which is why this is declared per page and not per page name. */}}
block in layout.html). It is declared per page rather than per page name
because how wide a page needs to be is a property of that page, not of the
navigation entry several pages share. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
+2 -2
View File
@@ -1,6 +1,6 @@
{{define "deliveries_rows"}}
<div id="deliveries-rows" hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="every 5s" hx-swap="outerHTML">
<div id="deliveries-rows" data-poll hx-get="/deliveries/rows?domain={{.FilterDomain}}&app={{.FilterApp}}&p={{.Page}}"
hx-trigger="load" hx-swap="outerHTML">
{{if .Rows}}
<table>
<thead>
+111 -42
View File
@@ -1,55 +1,124 @@
{{/* One message's page. It takes the whole column rather than the reading
measure (see the "wide" block in layout.html): the two cards below stand
side by side, and under them are raw mail.log lines, which are long by
nature and were the reason the send log opted out too. */}}
{{define "wide"}}wide{{end}}
{{define "content"}}
<h1>Delivery</h1>
{{/* The subject is the page's name — it is what the message was about, and
what an operator arriving from the log is holding in their head. The route
under it is the other half of the identity: who it was from and who it was
for, with the outcome badged on the end so the answer is on the first line
of the page rather than somewhere in the cards below. */}}
<h1 class="subject">{{if .Row.Subject}}{{.Row.Subject}}{{else}}(no subject){{end}}</h1>
<p class="route">
<span class="addr">{{.Row.From}}</span>
<span class="arrow" aria-hidden="true">&rarr;</span>
<span class="addr">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<span class="st st-{{.Level}}">{{.Row.Status}}</span>
</p>
<a class="back" href="{{.BackURL}}">&larr; Back to deliveries</a>
<div class="card">
<h2>Message</h2>
<p class="muted">One recipient of one message, as the journal-milter recorded
it when Postfix accepted it. The delivery log lists these; everything the list
has no room for is here.</p>
{{/* The two columns: what was recorded on the left, in what order it happened
on the right. They are a pair — the facts are only worth reading against
the history and the history only means anything for a known message — so
they are read together rather than one after the other (.split in
panel.css lays them down into one column when there is no room). */}}
<div class="split">
<div class="card" id="message">
<h2>Message</h2>
<p class="muted">What the journal recorded as Postfix accepted this
message. The delivery log lists only what identifies a row at a glance;
everything the table has no column for is here.</p>
<label>Time</label>
<span class="code">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<div class="facts">
<div class="fact">
<span class="fact-label">Domain</span>
<span class="fact-value">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Application</span>
<span class="fact-value">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Accepted</span>
<span class="fact-value">{{.Row.CreatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
<div class="fact">
<span class="fact-label">Status reported</span>
<span class="fact-value">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
</div>
{{/* The queue id is monospace because it is an identifier to compare
character by character against a log line, not a word to read. */}}
<div class="fact">
<span class="fact-label">Queue id</span>
<span class="fact-value mono">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
</div>
<div class="fact">
<span class="fact-label">Journal id</span>
<span class="fact-value mono">{{.Row.ID}}</span>
</div>
</div>
<label>From</label>
<span class="code">{{.Row.From}}</span>
{{if not .Row.QueueID}}
<p class="muted">A message with no queue id was refused before Postfix
queued it — under a level-2 rate limit — so it has no delivery attempt to
show.</p>
{{end}}
</div>
<label>To</label>
<span class="code">{{if .Row.To}}{{.Row.To}}{{else}}—{{end}}</span>
<div class="card" id="history">
<h2>History</h2>
<p class="muted">A row is written when the message is accepted and updated
once when Postfix reports the attempt for this recipient, so these are the
two moments the journal knows about — not a trace of the SMTP conversation,
which is in the delivery log below.</p>
<label>Subject</label>
<span class="code">{{if .Row.Subject}}{{.Row.Subject}}{{else}}—{{end}}</span>
<label>Status</label>
<span class="code">{{.Row.Status}}</span>
<ol class="timeline">
{{range .Events}}
<li class="event lvl-{{.Level}}{{if .At.IsZero}} pending{{end}}">
<p class="event-time">{{if .At.IsZero}}not yet{{else}}{{.At.Format "2006-01-02 15:04:05"}} UTC{{end}}</p>
<p class="event-title"><span class="st st-{{.Level}}">{{.Status}}</span> {{.Title}}</p>
<p class="event-detail muted">{{.Detail}}</p>
</li>
{{end}}
</ol>
</div>
</div>
<div class="card">
<h2>Origin</h2>
<p class="muted">Which sending domain and which application's SASL account the
message was submitted under — the two filters the delivery log offers.</p>
{{/* Under both, at the column's full width: what Postfix itself wrote. The
queue id used to be printed on this page as something to go and search the
system log for by hand — this is that search, already done. */}}
<div class="card" id="delivery-log">
<h2>Delivery log</h2>
<p class="muted">The lines Postfix wrote about this message in
<code>mail.log</code>, oldest first — the connection to the receiving server,
its reply, and the status that reply was filed as. Only lines carrying this
message's queue id are shown; the whole log is on the
<a href="/system-log">System log</a> page.</p>
<label>Domain</label>
<span class="code">{{if .Row.Domain}}{{.Row.Domain}}{{else}}—{{end}}</span>
<label>Application</label>
<span class="code">{{if .Row.AppLogin}}{{.Row.AppLogin}}{{else}}—{{end}}</span>
</div>
<div class="card">
<h2>Handling</h2>
<p class="muted">Postfix's own identifiers for this message. The queue id is
what to search the system log for; a rejected message never got one, because
it was refused before it was queued.</p>
<label>Queue id</label>
<span class="code">{{if .Row.QueueID}}{{.Row.QueueID}}{{else}}—{{end}}</span>
<label>Status last reported</label>
<span class="code">{{.Row.UpdatedAt.Format "2006-01-02 15:04:05"}} UTC</span>
<label>Journal id</label>
<span class="code">{{.Row.ID}}</span>
{{if .LogRows}}
{{/* Two columns, the way the send log itself is a table: the times down one
edge are what the message's pace is read off — the seconds between the
connection and the reply — and lining them up is what makes that
readable. A line whose head was not a timestamp keeps its whole text in
the second column and leaves the first empty. */}}
<table class="log">
<thead>
<tr><th>Time</th><th>Message</th></tr>
</thead>
<tbody>
{{range .LogRows}}
<tr>
<td class="time muted">{{if .Time}}{{.Time}}{{else}}—{{end}}</td>
<td class="log-text">{{.Text}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted">{{.LogNote}}</p>
{{end}}
</div>
{{end}}
+1 -1
View File
@@ -299,7 +299,7 @@
<p class="muted"><strong>The export file is a secret</strong> — it contains the
private DKIM key and application passwords. Transfer it securely and delete it
after the import, or encrypt it below and move a <code>.spde</code> file
instead; the import form asks for the password.</p>
(SelfPost domain export) instead; the import form asks for the password.</p>
{{if .ExportErr}}<p class="error">{{.ExportErr}}</p>{{end}}
<form method="post" action="/domains/{{.Domain.ID}}/export">
{{template "encryptfields" .}}
+4 -3
View File
@@ -1,9 +1,10 @@
{{/* Password fields shared by the full-backup and domain-export forms. The
checkbox is the switch: unticked, the download keeps its historic plain
form (.tar.gz / .json); ticked, the file is sealed in a password-encrypted
envelope (.spbk / .spde). The fields start hidden and are revealed by
panel.js — with JavaScript blocked they are simply always visible, and the
server still decides from the checkbox alone. */}}
envelope (.spbk = SelfPost backup, .spde = SelfPost domain export). The
fields start hidden and are revealed by panel.js — with JavaScript blocked
they are simply always visible, and the server still decides from the
checkbox alone. */}}
{{define "encryptfields"}}
<div class="encrypt">
<label class="check">
+2 -2
View File
@@ -69,8 +69,8 @@
</div>
{{template "sections" .}}
<div class="session">
<span class="muted">{{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Account</span>{{else}}<a href="/account">{{template "icon-account"}}Account</a>{{end}}
<span class="muted">User: {{.User}}</span>
{{if eq .Active "account"}}<span aria-current="page">{{template "icon-account"}}Settings</span>{{else}}<a href="/account">{{template "icon-account"}}Settings</a>{{end}}
<form class="inline" method="post" action="/logout">
<button type="submit" class="danger">{{template "icon-sign-out"}}Sign out</button>
</form>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "mail_queue_body"}}
<div id="mail-queue-body" hx-get="/mail-queue/body" hx-trigger="every 5s" hx-swap="outerHTML">
<div id="mail-queue-body" data-poll hx-get="/mail-queue/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Output}}{{.Output}}{{else}}Queue is empty.{{end}}</span>
</div>
+1 -1
View File
@@ -44,7 +44,7 @@
{{/* The status page's section index, shown in the navigation column (see the
"sections" block in layout.html). The first six cards are the ones the
polling fragment replaces every five seconds; their ids are part of
polling fragment replaces on a timer; their ids are part of
status_body.html and do not change with the reading, so the links here hold
across a refresh. */}}
{{define "sections"}}
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "status_body"}}
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
<div id="status-body" data-poll hx-get="/status/fragment" hx-trigger="load" hx-swap="outerHTML">
<div class="card" id="overall">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
+1 -1
View File
@@ -1,5 +1,5 @@
{{define "system_log_body"}}
<div id="system-log-body" hx-get="/system-log/body" hx-trigger="every 5s" hx-swap="outerHTML">
<div id="system-log-body" data-poll hx-get="/system-log/body" hx-trigger="load" hx-swap="outerHTML">
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<span class="code">{{if .Lines}}{{range .Lines}}{{.}}
{{end}}{{else}}No log lines yet.{{end}}</span>
+2 -2
View File
@@ -225,7 +225,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
wide := map[string]bool{"deliveries": true, "mail_queue": true, "system_log": true}
wide := map[string]bool{"deliveries": true, "delivery": true, "mail_queue": true, "system_log": true}
for name, page := range tmpl.pages {
var buf bytes.Buffer
if err := page.ExecuteTemplate(&buf, "wide", nil); err != nil {
@@ -236,7 +236,7 @@ func TestOnlyThePagesMadeOfDataDeclareThemselvesWide(t *testing.T) {
case wide[name] && got != "wide":
t.Errorf("page %q no longer declares itself wide (%q); its data falls back to the reading measure", name, got)
case !wide[name] && got != "":
t.Errorf("page %q declares itself %q; only the pages that are tables of data or raw log lines take the whole column", name, got)
t.Errorf("page %q declares itself %q; only the pages that are tables of data, raw log lines or side-by-side cards take the whole column", name, got)
}
}
}
+5 -4
View File
@@ -24,7 +24,7 @@ var assetsFS embed.FS
// Config holds the panel's HTTP-facing configuration.
type Config struct {
// Hostname is the server's external hostname, used to build the absolute
// setup link shown in the logs (security.md; README § Environment
// setup link shown in the logs (security.md; guide § Environment
// variables for SELFPOST_HOSTNAME).
Hostname string
// CookieSecure sets the Secure attribute on the session cookie. It defaults
@@ -53,7 +53,7 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (README §
// TLSCertFile is the certificate Postfix serves on 465/587 (guide §
// Environment variables), read read-only by the status page to report how
// much validity is left.
TLSCertFile string
@@ -147,8 +147,9 @@ func (s *Server) Handler() http.Handler {
// Health check stays unauthenticated for the container/orchestrator.
mux.HandleFunc("/healthz", handleHealth)
// Vendored static assets (HTMX). Served from the embedded FS.
mux.Handle("/static/", http.FileServer(http.FS(assetsFS)))
// Vendored static assets (HTMX). Served from the embedded FS, with a
// content ETag so a replaced asset survives the browser cache (static.go).
mux.Handle("/static/", staticHandler())
// One-time administrator setup (security.md).
mux.HandleFunc("/setup/", s.handleSetup)