docs: split README into overview and operator guide for release
test / test (push) Has been cancelled

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mixeme
2026-08-08 10:36:24 +03:00
parent 2f01edff19
commit c304c92955
24 changed files with 493 additions and 433 deletions
+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)
+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
+14 -7
View File
@@ -40,7 +40,7 @@ const (
// 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
)
@@ -280,12 +280,19 @@ 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. The first is what Postfix's own
// postlogd writes, which is what this server runs (maillog_file in
// build/postfix-config.sh) — RFC 3339 down to microseconds and with an offset.
// The second is syslog's traditional format, for a deployment that routes the
// log through syslogd instead; it carries no year and no zone, which is why it
// is not the one being matched first.
// 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`)
+11 -2
View File
@@ -465,7 +465,7 @@ func TestSplitTimestamp(t *testing.T) {
stamp, rest string
}{
{
name: "postlogd, which is what this server writes",
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)",
@@ -483,11 +483,20 @@ func TestSplitTimestamp(t *testing.T) {
rest: "mail opendkim[30]: 4A1B2C3D: DKIM-Signature field added",
},
{
name: "syslog's traditional format, padded day",
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)",
+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() {
+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
+2 -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.
+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.
+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 {
+2 -2
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