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
+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 {