release: 1.3.0: 1.3.0-1.3.1: security pass, queue persist, full backup layout

[1.3.0]
Security and quality after 1.2.5: domain-admin send-log authorization, fail-closed sign-in and application delete, level-2 rate-limit race fix, restore Resync, expanded tests, operator docs, release CI, and OFL for IBM Plex. Upgrading from 1.2.x is a tag bump; no migration.
Added:
  - licence: the SIL Open Font License 1.1 text now travels with the IBM Plex
  - docs: agreed roadmap item **queue-retries** — show this Postfix's retry
Security:
  - The independent security review of the send-log authorization and
Fixed:
  - test (e2e): send-log status scrapers follow the badge markup in
  - security (panel): the Deliveries list is scoped to a domain administrator's
  - mail (level-2 rate limit): the ceiling is no longer overshot by messages that
  - panel (sign-in): a session that cannot be written to the database no longer
  - panel (applications): deleting an application removes its SASL credentials
  … (2 more in CHANGELOG)
Changed:
  - docs: operator and as-built docs aligned with the code after a full
  - docs: [guide.md](docs/guide.md) reorganised into **Installation**, **Instance
  - docs: the 2026-08-13 full-tree review plan is complete — every phase (P0–P7)
  - licence: [NOTICE](NOTICE) tells modifiers to update `SourceURL` in
  - ci: the release image is published only for a **published** GitHub Release
  … (9 more in CHANGELOG)
[1.3.1]
Retry policy in the panel, persistent Postfix queue, and self-contained full backups. Mail queue and delivery history show this Postfix's first retry, backoff cap, and queue lifetime. Deferred mail survives container recreate. Full backups now archive `data/`, compose, `.env`, and `certs/`; the archive layout breaks 1.3.0 flat backups (unpack those with `tar xzf backup.tar.gz -C ./data` as before). Upgrading from 1.3.0 is a tag bump; no schema migration.
Added:
  - Mail queue and a delivery's history show this Postfix's retry policy
  - docs: **Plan checklists** in [development.md](docs/development.md) — format,
  - docs: **Implementation checklists** with model routing in
  - docs: roadmap candidates **send-log-retention** (panel Settings for delivery
  - Postfix queue under `/data/postfix/queue` — deferred and active mail survive
  … (1 more in CHANGELOG)
Changed:
  - **Breaking:** full-backup archive layout — paths are prefixed with `data/`;
  - docs: operator guide, architecture, security, and backup UI updated for
  - docs: HTML mockups for a full panel UI refresh
Changed:
  - docs: panel UI mockups after review — Status restored to live readings
  - docs: panel UI design system and mockups rebuilt as separate pages
  - docs: panel UI mockups — add-domain sits in the list card (Domains and
Fixed:
  - ci (GHCR): per-arch package tags (`X.Y.Z-amd64`, `X.Y.Z-arm64`) are dropped

Squashed CHANGELOG sections: [1.3.0], [1.3.1].

Full detail: CHANGELOG.md (1.3.0, 1.3.1).
This commit is contained in:
mix
2026-09-22 15:24:52 +03:00
parent 60e08e802e
commit 0730b2090e
124 changed files with 8464 additions and 1121 deletions
+269 -10
View File
@@ -8,7 +8,9 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/mixeme/selfpost/internal/postfix"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
)
@@ -129,7 +131,7 @@ func TestDeliveryPageMarksAQueuedMessageAsStillWaiting(t *testing.T) {
}); err != nil {
t.Fatalf("insert: %v", err)
}
rows, err := h.store.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
@@ -215,6 +217,174 @@ func TestDeliveryPageNotFound(t *testing.T) {
}
}
// A domain administrator reads the journal of the domains assigned to them and
// nothing else. The list used to be scoped only when exactly one domain was
// assigned, which meant two assignments read as none at all.
func TestSendLogScopedToAssignedDomains(t *testing.T) {
h, domains := serverWithTwoDomains(t)
for name, tc := range map[string]struct {
username string
domainIDs []int64
want []string
unwanted []string
}{
"global sees both": {
"", nil, []string{"First message", "Second message"}, nil,
},
"one assigned domain": {
"one-domain", []int64{domains["first.example.ru"].ID},
[]string{"First message"}, []string{"Second message", "second-app"},
},
"two assigned domains": {
"two-domains", []int64{domains["first.example.ru"].ID, domains["second.example.ru"].ID},
[]string{"First message", "Second message"}, nil,
},
// Every assigned domain deleted cascades the assignments away. That
// leaves a principal entitled to nothing, which is an empty log — the
// case that used to hand over the whole journal.
"no assigned domains": {
"no-domains", nil, []string{"No messages logged yet."},
[]string{"First message", "Second message"},
},
} {
var p auth.Principal
if tc.username == "" {
p = globalPrincipal
} else {
p = domainAdmin(t, h.store, tc.username, tc.domainIDs...)
}
for view, handler := range map[string]http.HandlerFunc{
"page": h.HandleDeliveries,
"fragment": h.HandleDeliveriesRows,
} {
out := getBodyAs(t, handler, "/deliveries", p)
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("%s (%s): missing %q:\n%s", name, view, want, out)
}
}
for _, unwanted := range tc.unwanted {
if strings.Contains(out, unwanted) {
t.Errorf("%s (%s): leaks %q:\n%s", name, view, unwanted, out)
}
}
}
}
}
// The filter dropdowns offer only permitted values, so a leak through them can
// only come from a hand-written URL — which is exactly why the values are
// checked against the principal's own domains and applications rather than
// trusted for having been rendered by us.
func TestSendLogIgnoresForgedFilters(t *testing.T) {
h, domains := serverWithTwoDomains(t)
p := domainAdmin(t, h.store, "forged-filter", domains["first.example.ru"].ID)
for _, target := range []string{
"/deliveries?domain=second.example.ru",
"/deliveries?app=second-app",
"/deliveries?domain=second.example.ru&app=second-app",
} {
out := getBodyAs(t, h.HandleDeliveries, target, p)
if strings.Contains(out, "Second message") {
t.Errorf("GET %s leaks another domain's journal:\n%s", target, out)
}
if !strings.Contains(out, "First message") {
t.Errorf("GET %s hid the principal's own journal:\n%s", target, out)
}
}
}
// The detail page has always checked membership; keep it checked, because the
// list and the page are two ways to the same row.
func TestDeliveryPageForeignDomainNotFound(t *testing.T) {
h, domains := serverWithTwoDomains(t)
rows, err := h.store.QuerySendLog(store.SendLogFilter{Domain: "second.example.ru", AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/deliveries/"+itoa(rows[0].ID), nil)
req.SetPathValue("id", itoa(rows[0].ID))
req = auth.RequestWithPrincipal(req, domainAdmin(t, h.store, "foreign-detail", domains["first.example.ru"].ID))
h.HandleDelivery(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("delivery page for a foreign domain = %d, want 404", rec.Code)
}
}
// serverWithTwoDomains builds a panel over a store holding two domains, one
// application and one delivered message each, so a scoping test can tell "my
// rows" from "every row" by reading the page.
func serverWithTwoDomains(t *testing.T) (*Handlers, map[string]store.Domain) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
domains := make(map[string]store.Domain, 2)
for _, d := range []struct{ name, app, subject string }{
{"first.example.ru", "first-app", "First message"},
{"second.example.ru", "second-app", "Second message"},
} {
dom, err := st.AddDomain(d.name, "mail")
if err != nil {
t.Fatalf("add domain %s: %v", d.name, err)
}
if _, err := st.AddApplication(dom.ID, d.app, store.AddressModeWildcard, nil); err != nil {
t.Fatalf("add application %s: %v", d.app, err)
}
if err := st.InsertQueued(store.SendLogEntry{
QueueID: "Q" + d.app, Domain: d.name, AppLogin: d.app,
From: "noreply@" + d.name, To: "public@example.net", Subject: d.subject,
}); err != nil {
t.Fatalf("insert %s: %v", d.subject, err)
}
domains[d.name] = dom
}
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, domains
}
var globalPrincipal = auth.Principal{ID: 1, Username: "admin", Role: auth.RoleGlobal}
func domainAdmin(t *testing.T, st *store.Store, username string, domainIDs ...int64) auth.Principal {
t.Helper()
const hash = "test-hash"
if len(domainIDs) == 0 {
placeholder, err := st.AddDomain(username+".placeholder.invalid", "mail")
if err != nil {
t.Fatalf("add placeholder domain: %v", err)
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, []int64{placeholder.ID})
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
if err := st.DeleteDomain(placeholder.ID); err != nil {
t.Fatalf("delete placeholder domain: %v", err)
}
domainIDs = nil
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
id, err := st.CreateUser(username, hash, store.RoleDomainAdmin, domainIDs)
if err != nil {
t.Fatalf("create domain admin %s: %v", username, err)
}
u, err := st.GetUser(id)
if err != nil {
t.Fatalf("get domain admin %s: %v", username, err)
}
return auth.Principal{ID: u.ID, Username: u.Username, Role: u.Role, Domains: u.DomainIDs}
}
// serverWithDelivery builds a panel over a store holding one delivery, written
// the way the journal-milter wrote them before it decoded subjects itself.
func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
@@ -238,7 +408,7 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
if _, err := st.UpdateStatus("4A1B2C3D", "public@example.ru", store.StatusSent); err != nil {
t.Fatalf("update status: %v", err)
}
rows, err := st.QuerySendLog(store.SendLogFilter{}, 1, 0)
rows, err := st.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
if err != nil || len(rows) != 1 {
t.Fatalf("query: %v (%d rows)", err, len(rows))
}
@@ -246,18 +416,20 @@ func serverWithDelivery(t *testing.T) (*Handlers, store.SendLogRow) {
return &Handlers{store: st, view: mustView(t), cfg: Config{Version: "test"}}, rows[0]
}
// getBody runs one handler over a GET and returns the page it wrote, failing
// the test on any non-200. The path's {id} is bound by hand because these calls
// bypass the router that would otherwise fill it in.
// getBody runs one handler over a GET as the global administrator.
func getBody(t *testing.T, h http.HandlerFunc, target string) string {
t.Helper()
return getBodyAs(t, h, target, globalPrincipal)
}
// getBodyAs runs one handler over a GET as the given principal and returns the
// page it wrote, failing the test on any non-200. The path's {id} is bound by
// hand because these calls bypass the router that would otherwise fill it in.
func getBodyAs(t *testing.T, h http.HandlerFunc, target string, p auth.Principal) string {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, target, nil)
req = auth.RequestWithPrincipal(req, auth.Principal{
ID: 1,
Username: "admin",
Role: auth.RoleGlobal,
})
req = auth.RequestWithPrincipal(req, p)
if rest, ok := strings.CutPrefix(req.URL.Path, "/deliveries/"); ok && rest != "rows" {
req.SetPathValue("id", rest)
}
@@ -280,3 +452,90 @@ func writeMailLog(t *testing.T, lines ...string) string {
}
return path
}
// fixtureRetryPolicy is a distinctive policy so tests can tell the Config
// snapshot from live postconf and from compiled-in defaults (5 minutes / 5 days).
func fixtureRetryPolicy() postfix.RetryPolicy {
return postfix.RetryPolicy{
QueueRunDelay: 10 * time.Minute,
MinimalBackoff: 10 * time.Minute,
MaximalBackoff: 4000 * time.Second,
MaximalQueueLifetime: 2 * 24 * time.Hour,
BounceQueueLifetime: 2 * 24 * time.Hour,
}
}
// The retry card sits on the page itself, outside the HTMX poll, and prints
// whatever policy was cached on Config — never a live postconf.
func TestMailQueueShowsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{Version: "test", RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
for _, want := range []string{
"How delivery retries work",
"id=\"retry-policy\"",
">10 minutes<",
"doubling, cap about 1 hour 7 minutes",
">2 days<",
} {
if !strings.Contains(out, want) {
t.Errorf("mail queue is missing %q:\n%s", want, out)
}
}
if strings.Contains(out, ">5 minutes<") || strings.Contains(out, ">5 days<") {
t.Errorf("mail queue shows stock defaults instead of the fixture:\n%s", out)
}
if strings.Contains(out, "compiled-in defaults") {
t.Error("a fixture policy must not show the fallback note")
}
}
func TestMailQueueBodyOmitsRetryPolicyCard(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: fixtureRetryPolicy()}}
out := getBody(t, h.HandleMailQueueBody, "/mail-queue/body")
if strings.Contains(out, "How delivery retries work") || strings.Contains(out, "10 minutes") {
t.Errorf("HTMX fragment includes the retry card:\n%s", out)
}
}
func TestMailQueueNotesCompiledInFallback(t *testing.T) {
h := &Handlers{view: mustView(t), cfg: Config{RetryPolicy: postfix.DefaultRetryPolicy()}}
out := getBody(t, h.HandleMailQueue, "/mail-queue")
if !strings.Contains(out, "compiled-in defaults") {
t.Errorf("fallback note missing:\n%s", out)
}
}
func TestDeliveryPageDeferredUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusDeferred); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
for _, want := range []string{
"first after 10 minutes",
"up to about 1 hour 7 minutes",
"for up to 2 days",
} {
if !strings.Contains(out, want) {
t.Errorf("deferred history is missing %q:\n%s", want, out)
}
}
}
func TestDeliveryPageBouncedUsesRetryPolicy(t *testing.T) {
h, row := serverWithDelivery(t)
h.cfg.RetryPolicy = fixtureRetryPolicy()
if _, err := h.store.UpdateStatus(row.QueueID, row.To, store.StatusBounced); err != nil {
t.Fatalf("update status: %v", err)
}
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "gave up after 2 days in the queue") {
t.Errorf("bounced history does not use the fixture lifetime:\n%s", out)
}
}