Add send-log retention days to panel Settings.

Global administrators can set delivery journal retention (7-365 days) in SQLite;
SEND_LOG_RETENTION_DAYS seeds the initial default and the log-tailer re-reads
the value every prune cycle without a restart.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 21:57:48 +03:00
parent d0bf4a4226
commit 909145e5c8
20 changed files with 575 additions and 102 deletions
+12
View File
@@ -5,6 +5,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
## [Unreleased] ## [Unreleased]
### Added
- Panel **Settings** (global administrator): **Send log retention (days)**
how long delivery journal rows on `/deliveries` are kept (7365). Stored in
SQLite `settings`; `SEND_LOG_RETENTION_DAYS` seeds the initial default only.
The log-tailer re-reads the value every prune cycle (no restart).
### Changed
- Deliveries list and delivery detail pages show the configured retention
instead of a hardcoded ninety-day message.
## [1.4.0] - 2026-08-17 ## [1.4.0] - 2026-08-17
Optional inbound relay (backup-MX / forwarder) on port 25, off by default. Optional inbound relay (backup-MX / forwarder) on port 25, off by default.
+3 -2
View File
@@ -96,8 +96,9 @@ func newPanel(cfg config, st *store.Store) (*web.Server, error) {
DNSResolvers: cfg.dnsResolvers, DNSResolvers: cfg.dnsResolvers,
RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.rateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.rateLimitWindowSeconds, RateLimitWindowSeconds: cfg.rateLimitWindowSeconds,
RetryPolicy: retryPolicy, RetryPolicy: retryPolicy,
InboundEnabled: cfg.inboundEnabled, InboundEnabled: cfg.inboundEnabled,
SendLogRetentionEnvDefault: cfg.retentionDays,
}, cfg.setupTokenPath) }, cfg.setupTokenPath)
} }
+14 -1
View File
@@ -249,6 +249,10 @@ func run() error {
} }
} }
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
return fmt.Errorf("bootstrap send-log retention: %w", err)
}
var wg sync.WaitGroup var wg sync.WaitGroup
errc := make(chan error, 3) errc := make(chan error, 3)
@@ -258,7 +262,16 @@ func run() error {
}{ }{
{"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }}, {"http", func(ctx context.Context) error { return serveHTTP(ctx, cfg, st) }},
{"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }}, {"journal-milter", func(ctx context.Context) error { return serveJournal(ctx, cfg, st) }},
{"log-tailer", func(ctx context.Context) error { return logtail.Run(ctx, cfg.mailLog, st, cfg.retentionDays) }}, {"log-tailer", func(ctx context.Context) error {
return logtail.Run(ctx, cfg.mailLog, st, func() int {
days, err := st.GetSendLogRetentionDays(cfg.retentionDays)
if err != nil {
log.Printf("send-log retention: %v", err)
return cfg.retentionDays
}
return days
})
}},
} }
for _, r := range roles { for _, r := range roles {
+8 -5
View File
@@ -196,7 +196,7 @@ expected to set; defaults match the code exactly.
| `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` | | `INBOUND_MESSAGE_SIZE_LIMIT` | Maximum message size in bytes on inbound smtpd (`message_size_limit`). | `26214400` (25 MiB) | `.env` |
| `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` | | `RATE_LIMIT_MESSAGES_PER_IP` | Level-1 backstop: maximum messages one client IP may submit per window (Postfix `smtpd_client_message_rate_limit`). See [Rate limiting — level 1](#rate-limiting--level-1-ip-backstop). | `100` | `.env` |
| `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` | | `RATE_LIMIT_WINDOW_SECONDS` | Level-1 window length in seconds (Postfix `anvil_rate_time_unit`). | `3600` | `.env` |
| `SEND_LOG_RETENTION_DAYS` | Days of send-log history kept before the background sweep deletes rows — the main driver of `/data` growth over time. | `90` | `.env` | | `SEND_LOG_RETENTION_DAYS` | Initial default for how many days of send-log history are kept before the background sweep deletes rows — the main driver of `/data` growth over time. After the first panel start, change retention on **Settings** (global administrator); the env value is only used to seed SQLite when the setting has never been saved. | `90` | `.env` |
| `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` | | `PANEL_SESSION_IDLE_DAYS` | Sliding idle timeout for the panel login session, in days. There is no absolute cap: an admin who keeps coming back stays signed in indefinitely. | `7` | `.env` |
| `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` | | `SELFPOST_DNS_RESOLVERS` | Comma-separated recursive resolvers the panel's PTR/SPF/DKIM/DMARC checks query directly (so they report what the internet sees, not what this host's stub resolver synthesises). | `1.1.1.1:53`, `8.8.8.8:53`, `9.9.9.9:53` when unset | `.env` |
| `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` | | `TRUSTED_PROXY_CIDR` | Comma-separated CIDRs (bare IPs allowed) of reverse proxies allowed to supply `X-Forwarded-For` for login, setup, and account-change rate-limiting. **Leave unset unless you know the exact address of your reverse proxy.** A wrong value lets a client spoof its rate-limit key by sending a forged `X-Forwarded-For` header — the panel trusts the last hop only when the TCP peer matches one of these CIDRs. Behind the default Apache host-network setup this is typically the Docker bridge gateway, e.g. `172.18.0.1`. | *(empty — XFF ignored)* | `.env` |
@@ -347,8 +347,10 @@ shows after manual edits under `/data`.
`/settings` changes the signed-in user's username and/or password. **Global `/settings` changes the signed-in user's username and/or password. **Global
administrators** also set the panel-wide default DMARC report address (`rua=`) administrators** also set the panel-wide default DMARC report address (`rua=`)
offered when a domain doesn't set its own — see offered when a domain doesn't set its own — see
[Domain-level DNS](#domain-level-dns-spf-dkim-dmarc). Application SASL logins [Domain-level DNS](#domain-level-dns-spf-dkim-dmarc) — and how many days
are separate and are not changed here. **Deliveries** rows are kept before the background sweep deletes them (7365
days; takes effect on the next six-hour prune cycle without a container
restart). Application SASL logins are separate and are not changed here.
### Users ### Users
@@ -741,8 +743,9 @@ administrators see them here because they cannot open Mail queue. Under
both sit the `mail.log` lines for its queue id: the connection to the both sit the `mail.log` lines for its queue id: the connection to the
receiving server, the server's reply, and the status that reply was filed receiving server, the server's reply, and the status that reply was filed
as. Rows outlive `mail.log`, so an older message's lines may have rotated as. Rows outlive `mail.log`, so an older message's lines may have rotated
away; the page says so. Retention is controlled by away; the page says so. Retention is set on **Settings** (global
`SEND_LOG_RETENTION_DAYS`. administrator); `SEND_LOG_RETENTION_DAYS` in `.env` is only the initial
default until it is changed there.
### Exporting and importing a single domain ### Exporting and importing a single domain
+9 -9
View File
@@ -1,6 +1,6 @@
# Plan: send-log-retention # Plan: send-log-retention
**Status:** candidate **Status:** done
**Date:** 2026-08-17 **Date:** 2026-08-17
**Version:** `1.x` MINOR; no schema migration required (uses existing `settings` table). **Version:** `1.x` MINOR; no schema migration required (uses existing `settings` table).
@@ -129,11 +129,11 @@ Target version cut: **`1.5.0`** (MINOR). One commit per step; code only after
roadmap status is **agreed**. See [development.md](../development.md) § Plan roadmap status is **agreed**. See [development.md](../development.md) § Plan
checklists. checklists.
- [ ] `GetSendLogRetentionDays` / `SetSetting` key `send_log_retention_days` (7365) — **Opus** - [x] `GetSendLogRetentionDays` / `SetSetting` key `send_log_retention_days` (7365) — **Opus**
- [ ] Bootstrap from `SEND_LOG_RETENTION_DAYS` when settings empty — **Opus** - [x] Bootstrap from `SEND_LOG_RETENTION_DAYS` when settings empty — **Opus**
- [ ] `logtail.retentionLoop`: read setting each prune cycle — **Opus** - [x] `logtail.retentionLoop`: read setting each prune cycle — **Opus**
- [ ] Settings card on `/settings` (`settings.html`) — **Sonnet** - [x] Settings card on `/settings` (`settings.html`) — **Sonnet**
- [ ] Remove hardcoded «ninety days» in handlers and templates — **Sonnet** - [x] Remove hardcoded «ninety days» in handlers and templates — **Sonnet**
- [ ] Tests: save/load, range, loop without restart — **Sonnet** - [x] Tests: save/load, range, loop without restart — **Sonnet**
- [ ] [guide.md](../guide.md) — **Sonnet** - [x] [guide.md](../guide.md) — **Sonnet**
- [ ] `go vet`, `go test` on touched packages — **Haiku** - [x] `go vet`, `go test` on touched packages — **Haiku**
+1 -1
View File
@@ -28,7 +28,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
| ID | Topic | Status | Progress | Plan | | ID | Topic | Status | Progress | Plan |
|---|---|---|---|---| |---|---|---|---|---|
| send-log-retention | Send-log retention days in panel Settings | candidate | 0/8 | [plans/send-log-retention.md](plans/send-log-retention.md) | | send-log-retention | Send-log retention days in panel Settings | done | 8/8 | [plans/send-log-retention.md](plans/send-log-retention.md) |
| domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) | | domain-stats-auto-ratelimit | 30-day send stats + auto level-2 rate limit | candidate | 0/11 | [plans/domain-stats-auto-ratelimit.md](plans/domain-stats-auto-ratelimit.md) |
| contributing | `CONTRIBUTING.md` | candidate | — | — | | contributing | `CONTRIBUTING.md` | candidate | — | — |
| dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) | | dmarc-reports | DMARC aggregate report ingestion and panel UI | candidate | 0/8 | [plans/dmarc-reports.md](plans/dmarc-reports.md) |
+16 -13
View File
@@ -39,17 +39,15 @@ type StatusStore interface {
// is a var so tests can shorten it. // is a var so tests can shorten it.
var pollInterval = time.Second var pollInterval = time.Second
// retentionInterval is how often the retention sweep runs (also once at
// startup). It is a var so tests can shorten it.
var retentionInterval = 6 * time.Hour
// queueIDs lists the messages Postfix currently holds, for the reconcile sweep. // queueIDs lists the messages Postfix currently holds, for the reconcile sweep.
// It is a var so tests can answer without a running Postfix. // It is a var so tests can answer without a running Postfix.
var queueIDs = postfix.QueueIDs var queueIDs = postfix.QueueIDs
const ( 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
// (guide § Environment variables: SEND_LOG_RETENTION_DAYS).
defaultRetentionDays = 90
// reconcileInterval is how often the sweep compares stuck rows against the // reconcileInterval is how often the sweep compares stuck rows against the
// Postfix queue, and reconcileGrace how long a row is left alone first. // 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 // The grace covers the ordinary lag between the milter writing the row and
@@ -59,6 +57,10 @@ const (
reconcileGrace = 2 * time.Minute reconcileGrace = 2 * time.Minute
) )
// RetentionDays returns the send-log retention window in days. The log-tailer
// calls it on every prune cycle so a panel change takes effect without restart.
type RetentionDays func() int
// deliveryRe matches a Postfix delivery line and captures queue-id, recipient // deliveryRe matches a Postfix delivery line and captures queue-id, recipient
// and status, e.g. // and status, e.g.
// //
@@ -98,11 +100,11 @@ func parseDelivery(line string) (queueID, recipient, status string, ok bool) {
} }
// Run follows path and updates send-log statuses until ctx is cancelled, while // Run follows path and updates send-log statuses until ctx is cancelled, while
// a background sweep prunes rows older than retentionDays. Reading resumes at // a background sweep prunes rows older than retention(). Reading resumes at
// the offset the previous run persisted, so a restart parses the delivery lines // the offset the previous run persisted, so a restart parses the delivery lines
// written while the panel was down. It returns nil on a clean shutdown. // written while the panel was down. It returns nil on a clean shutdown.
func Run(ctx context.Context, path string, st StatusStore, retentionDays int) error { func Run(ctx context.Context, path string, st StatusStore, retention RetentionDays) error {
go retentionLoop(ctx, st, retentionDays) go retentionLoop(ctx, st, retention)
// The reconcile sweep must not run against a backlog the tailer has not // 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 // read yet: on a restart the log holds the very lines that resolve the rows
@@ -192,11 +194,12 @@ func reconcile(st StatusStore, cutoff time.Time) {
} }
// retentionLoop prunes expired send-log rows immediately and then periodically. // retentionLoop prunes expired send-log rows immediately and then periodically.
func retentionLoop(ctx context.Context, st StatusStore, retentionDays int) { func retentionLoop(ctx context.Context, st StatusStore, retention RetentionDays) {
if retentionDays <= 0 {
retentionDays = defaultRetentionDays
}
prune := func() { prune := func() {
retentionDays := retention()
if retentionDays <= 0 {
retentionDays = store.SendLogRetentionDaysDefault
}
cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays) cutoff := time.Now().UTC().AddDate(0, 0, -retentionDays)
n, err := st.DeleteSendLogBefore(cutoff) n, err := st.DeleteSendLogBefore(cutoff)
if err != nil { if err != nil {
+2 -2
View File
@@ -212,7 +212,7 @@ func TestFollowTailsAndRotates(t *testing.T) {
cs := &captureStore{} cs := &captureStore{}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }() go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
// Give follow() time to open at EOF (it seeks to end immediately on start, // Give follow() time to open at EOF (it seeks to end immediately on start,
// so the seed line above is ignored), then append a delivery line. // so the seed line above is ignored), then append a delivery line.
@@ -295,7 +295,7 @@ func startRun(t *testing.T, path string, cs *captureStore) func() {
t.Helper() t.Helper()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1) done := make(chan error, 1)
go func() { done <- Run(ctx, path, cs, 90) }() go func() { done <- Run(ctx, path, cs, func() int { return 90 }) }()
// follow() opens and seeks on start; give it a moment before the caller // follow() opens and seeks on start; give it a moment before the caller
// appends, so the append is not raced by the initial open. // appends, so the append is not raced by the initial open.
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
+83
View File
@@ -0,0 +1,83 @@
package logtail
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/mixeme/selfpost/internal/store"
)
type retentionProbeStore struct {
mu sync.Mutex
cutoff time.Time
}
func (s *retentionProbeStore) UpdateStatus(string, string, string) (int64, error) {
return 0, nil
}
func (s *retentionProbeStore) ListQueuedOlderThan(time.Time) ([]store.QueuedDelivery, error) {
return nil, nil
}
func (s *retentionProbeStore) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
s.mu.Lock()
s.cutoff = cutoff
s.mu.Unlock()
return 0, nil
}
func (s *retentionProbeStore) LogtailState(string) (store.LogtailState, bool, error) {
return store.LogtailState{}, false, nil
}
func (s *retentionProbeStore) SaveLogtailState(string, store.LogtailState) error {
return nil
}
func (s *retentionProbeStore) lastCutoff() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.cutoff
}
// retentionLoop must re-read the configured window every cycle so a panel
// change takes effect without restarting the process.
func TestRetentionLoopUsesUpdatedValue(t *testing.T) {
old := retentionInterval
retentionInterval = 20 * time.Millisecond
t.Cleanup(func() { retentionInterval = old })
var days atomic.Int32
days.Store(30)
st := &retentionProbeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go retentionLoop(ctx, st, func() int { return int(days.Load()) })
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 31*24*time.Hour && age > 29*24*time.Hour {
break
}
time.Sleep(5 * time.Millisecond)
}
if age := time.Since(st.lastCutoff()); age < 29*24*time.Hour || age > 31*24*time.Hour {
t.Fatalf("first prune cutoff age %v, want about 30 days", age)
}
days.Store(7)
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if age := time.Since(st.lastCutoff()); age < 8*24*time.Hour && age > 6*24*time.Hour {
return
}
time.Sleep(5 * time.Millisecond)
}
age := time.Since(st.lastCutoff())
t.Fatalf("second prune cutoff age %v, want about 7 days", age)
}
+71
View File
@@ -4,8 +4,79 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strconv"
) )
const (
// SendLogRetentionDaysKey is the settings table key for delivery-journal
// retention (architecture.md § Persistence).
SendLogRetentionDaysKey = "send_log_retention_days"
SendLogRetentionDaysMin = 7
SendLogRetentionDaysMax = 365
// SendLogRetentionDaysDefault matches SEND_LOG_RETENTION_DAYS when unset.
SendLogRetentionDaysDefault = 90
)
// ErrSendLogRetentionDaysOutOfRange is returned when retention is outside the
// allowed panel range.
var ErrSendLogRetentionDaysOutOfRange = errors.New("send log retention days out of range")
// ValidateSendLogRetentionDays checks the panel-allowed retention window.
func ValidateSendLogRetentionDays(days int) error {
if days < SendLogRetentionDaysMin || days > SendLogRetentionDaysMax {
return fmt.Errorf("%w: must be between %d and %d days", ErrSendLogRetentionDaysOutOfRange, SendLogRetentionDaysMin, SendLogRetentionDaysMax)
}
return nil
}
func sendLogRetentionFallback(envDefault int) int {
if envDefault > 0 {
if err := ValidateSendLogRetentionDays(envDefault); err == nil {
return envDefault
}
}
return SendLogRetentionDaysDefault
}
// GetSendLogRetentionDays returns the effective retention window. When the
// setting is missing or invalid, envDefault is used (guide § Environment
// variables: SEND_LOG_RETENTION_DAYS).
func (s *Store) GetSendLogRetentionDays(envDefault int) (int, error) {
raw, err := s.GetSetting(SendLogRetentionDaysKey)
if err != nil {
return 0, err
}
if raw == "" {
return sendLogRetentionFallback(envDefault), nil
}
days, err := strconv.Atoi(raw)
if err != nil || ValidateSendLogRetentionDays(days) != nil {
return sendLogRetentionFallback(envDefault), nil
}
return days, nil
}
// SetSendLogRetentionDays persists the panel-configured retention window.
func (s *Store) SetSendLogRetentionDays(days int) error {
if err := ValidateSendLogRetentionDays(days); err != nil {
return err
}
return s.SetSetting(SendLogRetentionDaysKey, strconv.Itoa(days))
}
// EnsureSendLogRetentionDays seeds the setting from envDefault when it has
// never been written (first panel start after upgrade).
func (s *Store) EnsureSendLogRetentionDays(envDefault int) error {
raw, err := s.GetSetting(SendLogRetentionDaysKey)
if err != nil {
return err
}
if raw != "" {
return nil
}
return s.SetSendLogRetentionDays(sendLogRetentionFallback(envDefault))
}
// GetSetting returns a settings value or empty string when missing. // GetSetting returns a settings value or empty string when missing.
func (s *Store) GetSetting(key string) (string, error) { func (s *Store) GetSetting(key string) (string, error) {
var value string var value string
+87
View File
@@ -0,0 +1,87 @@
package store
import (
"errors"
"testing"
)
func TestSendLogRetentionDaysSaveLoad(t *testing.T) {
st := openTestStore(t)
if err := st.SetSendLogRetentionDays(45); err != nil {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
got, err := st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 45 {
t.Fatalf("got %d, want 45", got)
}
}
func TestSendLogRetentionDaysRejectsOutOfRange(t *testing.T) {
st := openTestStore(t)
for _, days := range []int{0, 6, 366, -1} {
if err := st.SetSendLogRetentionDays(days); err == nil {
t.Fatalf("SetSendLogRetentionDays(%d) want error", days)
} else if !errors.Is(err, ErrSendLogRetentionDaysOutOfRange) {
t.Fatalf("SetSendLogRetentionDays(%d) = %v, want ErrSendLogRetentionDaysOutOfRange", days, err)
}
}
}
func TestSendLogRetentionDaysBootstrapFromEnv(t *testing.T) {
st := openTestStore(t)
if err := st.EnsureSendLogRetentionDays(120); err != nil {
t.Fatalf("EnsureSendLogRetentionDays: %v", err)
}
got, err := st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 120 {
t.Fatalf("got %d, want 120", got)
}
// Second call is a no-op.
if err := st.EnsureSendLogRetentionDays(30); err != nil {
t.Fatalf("EnsureSendLogRetentionDays again: %v", err)
}
got, err = st.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 120 {
t.Fatalf("after second ensure got %d, want 120", got)
}
}
func TestSendLogRetentionDaysMissingUsesEnvDefault(t *testing.T) {
st := openTestStore(t)
got, err := st.GetSendLogRetentionDays(60)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 60 {
t.Fatalf("got %d, want 60", got)
}
}
func TestSendLogRetentionDaysInvalidStoredFallsBack(t *testing.T) {
st := openTestStore(t)
if err := st.SetSetting(SendLogRetentionDaysKey, "not-a-number"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
got, err := st.GetSendLogRetentionDays(60)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 60 {
t.Fatalf("got %d, want env fallback 60", got)
}
}
+3
View File
@@ -38,6 +38,9 @@ type Config struct {
// InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and // InboundEnabled mirrors INBOUND_RELAY_ENABLE: the inbound panel and
// routes exist only when this is true. // routes exist only when this is true.
InboundEnabled bool InboundEnabled bool
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start; used
// as bootstrap and fallback when the settings row is missing or invalid.
SendLogRetentionEnvDefault int
} }
// Handlers holds dependencies for authenticated panel routes. // Handlers holds dependencies for authenticated panel routes.
+19 -19
View File
@@ -108,10 +108,11 @@ func (h *Handlers) HandleDelivery(w http.ResponseWriter, r *http.Request) {
row.Subject = mailhdr.DecodeSubject(row.Subject) row.Subject = mailhdr.DecodeSubject(row.Subject)
logRows, logNote := h.deliveryLog(row) logRows, logNote := h.deliveryLog(row)
h.view.Render(w, http.StatusOK, "delivery", map[string]any{ h.view.Render(w, http.StatusOK, "delivery", map[string]any{
"Title": "SelfPost — delivery", "Title": "SelfPost — delivery",
"User": auth.CurrentUser(r), "User": auth.CurrentUser(r),
"Active": "deliveries", "Active": "deliveries",
"IsGlobal": p.IsGlobal(), "IsGlobal": p.IsGlobal(),
"SendLogRetentionDays": h.sendLogRetentionDays(),
"Row": row, "Row": row,
// The status in the panel's own badge vocabulary, so the headline reads // The status in the panel's own badge vocabulary, so the headline reads
// the same way as every other health signal in the panel. // the same way as every other health signal in the panel.
@@ -270,10 +271,8 @@ func (h *Handlers) deliveryLog(row store.SendLogRow) ([]deliveryLogRow, string)
return nil, "Could not read the mail log." return nil, "Could not read the mail log."
} }
if len(lines) == 0 { if len(lines) == 0 {
// Send-log rows outlive mail.log: retention is ninety days by default days := h.sendLogRetentionDays()
// and rotation keeps fourteen files, so an older message having nothing return nil, fmt.Sprintf("Nothing for this queue id in the current mail log. Its lines have most likely been rotated away (send-log rows are kept for %d days).", days)
// 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)) out := make([]deliveryLogRow, len(lines))
for i, line := range lines { for i, line := range lines {
@@ -386,17 +385,18 @@ func (h *Handlers) sendLogData(r *http.Request) (map[string]any, error) {
lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize) lastPage = int((total + sendLogPageSize - 1) / sendLogPageSize)
} }
return map[string]any{ return map[string]any{
"Rows": view, "Rows": view,
"FilterDomains": domainNames, "FilterDomains": domainNames,
"FilterApps": logins, "FilterApps": logins,
"FilterDomain": filter.Domain, "FilterDomain": filter.Domain,
"FilterApp": filter.AppLogin, "FilterApp": filter.AppLogin,
"Page": page, "Page": page,
"PrevPage": page - 1, "PrevPage": page - 1,
"NextPage": page + 1, "NextPage": page + 1,
"LastPage": lastPage, "LastPage": lastPage,
"HasPrev": page > 1, "HasPrev": page > 1,
"HasNext": page < lastPage, "HasNext": page < lastPage,
"SendLogRetentionDays": h.sendLogRetentionDays(),
}, nil }, nil
} }
@@ -191,12 +191,19 @@ func TestDeliveryPageKeepsAnUnstampedLogLineWhole(t *testing.T) {
// queue at all. Neither is a fault, so neither may render as an error. // queue at all. Neither is a fault, so neither may render as an error.
func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) { func TestDeliveryPageExplainsAnEmptyDeliveryLog(t *testing.T) {
h, row := serverWithDelivery(t) h, row := serverWithDelivery(t)
h.cfg.SendLogRetentionEnvDefault = 60
if err := h.store.SetSendLogRetentionDays(60); err != nil {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created h.cfg.MailLogPath = filepath.Join(t.TempDir(), "mail.log") // never created
out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID)) out := getBody(t, h.HandleDelivery, "/deliveries/"+itoa(row.ID))
if !strings.Contains(out, "rotated away") { if !strings.Contains(out, "rotated away") {
t.Errorf("delivery page does not explain the empty delivery log:\n%s", out) t.Errorf("delivery page does not explain the empty delivery log:\n%s", out)
} }
if !strings.Contains(out, "kept for 60 days") {
t.Errorf("delivery page does not show configured retention:\n%s", out)
}
if strings.Contains(out, `class="error"`) || strings.Contains(out, "Could not read the mail log") { 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) t.Errorf("an aged-out delivery log is reported as a failure:\n%s", out)
} }
+100 -45
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -29,7 +30,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusOK, "", u.Username, u.DMARCReportEmail, h.sendLogRetentionDays(), p.IsGlobal())
case http.MethodPost: case http.MethodPost:
h.submitSettings(w, r) h.submitSettings(w, r)
default: default:
@@ -38,7 +39,7 @@ func (h *Handlers) HandleSettings(w http.ResponseWriter, r *http.Request) {
} }
} }
func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, showDMARC bool) { func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string, formSendLogRetentionDays int, showDMARC bool) {
var reportAuth dnscheck.Result var reportAuth dnscheck.Result
if showDMARC && formDMARCEmail != "" { if showDMARC && formDMARCEmail != "" {
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" { if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
@@ -52,6 +53,7 @@ func (h *Handlers) renderSettings(w http.ResponseWriter, r *http.Request, status
data["Active"] = "settings" data["Active"] = "settings"
data["FormUsername"] = formUsername data["FormUsername"] = formUsername
data["FormDMARCEmail"] = formDMARCEmail data["FormDMARCEmail"] = formDMARCEmail
data["FormSendLogRetentionDays"] = formSendLogRetentionDays
data["ShowDMARC"] = showDMARC data["ShowDMARC"] = showDMARC
data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)) data["ReportAuthName"] = dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail))
data["ReportAuthExample"] = dnscheck.ReportAuthExample() data["ReportAuthExample"] = dnscheck.ReportAuthExample()
@@ -74,6 +76,8 @@ func settingsFlash(r *http.Request) string {
return "Username and password changed. Any other signed-in sessions were signed out." return "Username and password changed. Any other signed-in sessions were signed out."
case "email": case "email":
return "DMARC report address updated." return "DMARC report address updated."
case "retention":
return "Send log retention updated."
case "username-email": case "username-email":
return "Username and DMARC report address updated." return "Username and DMARC report address updated."
case "password-email": case "password-email":
@@ -86,23 +90,23 @@ func settingsFlash(r *http.Request) string {
} }
func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) { func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
if !h.auth.AllowLoginAttempt(r) {
p, _ := h.principal(r)
h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
if err := r.ParseForm(); err != nil {
p, _ := h.principal(r)
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", p.IsGlobal())
return
}
p, ok := h.principal(r) p, ok := h.principal(r)
if !ok { if !ok {
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
currentRetention := h.sendLogRetentionDays()
if !h.auth.AllowLoginAttempt(r) {
h.renderSettings(w, r, http.StatusTooManyRequests,
"Too many attempts. Please wait and try again.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
return
}
if err := r.ParseForm(); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, "Invalid form submission.", auth.CurrentUser(r), "", currentRetention, p.IsGlobal())
return
}
user, err := h.store.GetUser(p.ID) user, err := h.store.GetUser(p.ID)
if err != nil { if err != nil {
logf("panel: settings: get user failed: %v", err) logf("panel: settings: get user failed: %v", err)
@@ -115,49 +119,66 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
password := r.PostFormValue("new_password") password := r.PostFormValue("new_password")
confirm := r.PostFormValue("new_password_confirm") confirm := r.PostFormValue("new_password_confirm")
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email")) dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
formRetention := currentRetention
if !p.IsGlobal() { if !p.IsGlobal() {
dmarcEmail = user.DMARCReportEmail dmarcEmail = user.DMARCReportEmail
} else if raw := strings.TrimSpace(r.PostFormValue("send_log_retention_days")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil {
h.renderSettings(w, r, http.StatusBadRequest, "Send log retention must be a whole number of days.", username, dmarcEmail, currentRetention, true)
return
}
formRetention = parsed
} }
if username == "" { if username == "" {
username = user.Username username = user.Username
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(current)); err != nil {
h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
renaming := username != user.Username renaming := username != user.Username
if renaming { if renaming {
if err := validate.Username(username); err != nil { if err := validate.Username(username); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
} }
if p.IsGlobal() { if p.IsGlobal() {
if err := validate.Email(dmarcEmail); err != nil { if err := validate.Email(dmarcEmail); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, true) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
return return
} }
} }
emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail emailChanging := p.IsGlobal() && dmarcEmail != user.DMARCReportEmail
retentionChanging := false
if p.IsGlobal() && formRetention != currentRetention {
if err := store.ValidateSendLogRetentionDays(formRetention); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, true)
return
}
retentionChanging = true
}
repassword := password != "" || confirm != "" repassword := password != "" || confirm != ""
if repassword { if repassword {
if password != confirm { if password != confirm {
h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
if err := validate.AdminPassword(password); err != nil { if err := validate.AdminPassword(password); err != nil {
h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, p.IsGlobal()) h.renderSettings(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
} }
if !renaming && !repassword && !emailChanging { if !renaming && !repassword && !emailChanging && !retentionChanging {
h.renderSettings(w, r, http.StatusBadRequest, h.renderSettings(w, r, http.StatusBadRequest,
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail, p.IsGlobal()) "Nothing to change: enter a new username, password, DMARC report address, or send log retention.", username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
@@ -167,25 +188,36 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
logf("panel: settings: hashing password failed: %v", err) logf("panel: settings: hashing password failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError, h.renderSettings(w, r, http.StatusInternalServerError,
"Internal error. Please try again.", username, dmarcEmail, p.IsGlobal()) "Internal error. Please try again.", username, dmarcEmail, formRetention, p.IsGlobal())
return return
} }
hash = string(newHash) hash = string(newHash)
} }
if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil { if renaming || repassword || emailChanging {
logf("panel: settings: update user failed: %v", err) if err := h.store.UpdateUser(user.ID, username, hash, dmarcEmail); err != nil {
msg := "Could not save the changes. Please check the logs and try again." logf("panel: settings: update user failed: %v", err)
if errors.Is(err, store.ErrUserNotFound) { msg := "Could not save the changes. Please check the logs and try again."
msg = "There is no user account to update." if errors.Is(err, store.ErrUserNotFound) {
} msg = "There is no user account to update."
if errors.Is(err, store.ErrUserExists) { }
msg = "That username is already in use." if errors.Is(err, store.ErrUserExists) {
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, p.IsGlobal()) msg = "That username is already in use."
h.renderSettings(w, r, http.StatusConflict, msg, username, dmarcEmail, formRetention, p.IsGlobal())
return
}
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, formRetention, p.IsGlobal())
return
}
}
if retentionChanging {
if err := h.store.SetSendLogRetentionDays(formRetention); err != nil {
logf("panel: settings: set send-log retention failed: %v", err)
h.renderSettings(w, r, http.StatusInternalServerError,
"Could not save send log retention. Please check the logs and try again.", username, dmarcEmail, formRetention, true)
return return
} }
h.renderSettings(w, r, http.StatusInternalServerError, msg, username, dmarcEmail, p.IsGlobal())
return
} }
if token, ok := h.auth.SessionToken(r); ok { if token, ok := h.auth.SessionToken(r); ok {
@@ -197,25 +229,48 @@ func (h *Handlers) submitSettings(w http.ResponseWriter, r *http.Request) {
} }
} }
logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t)", user.ID, renaming, repassword, emailChanging) logf("panel: user %d settings updated (username: %t, password: %t, dmarc email: %t, retention: %t)", user.ID, renaming, repassword, emailChanging, retentionChanging)
http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther) http.Redirect(w, r, "/settings?updated="+updatedFlag(renaming, repassword, emailChanging, retentionChanging), http.StatusSeeOther)
} }
func updatedFlag(renamed, repassword, emailChanged bool) string { func updatedFlag(renamed, repassword, emailChanged, retentionChanged bool) string {
switch { changed := 0
case renamed && repassword && emailChanged: if renamed {
changed++
}
if repassword {
changed++
}
if emailChanged {
changed++
}
if retentionChanged {
changed++
}
if changed > 1 {
return "all" return "all"
case renamed && emailChanged: }
return "username-email" switch {
case repassword && emailChanged:
return "password-email"
case renamed && repassword:
return "both"
case renamed: case renamed:
return "username" return "username"
case repassword: case repassword:
return "password" return "password"
default: case emailChanged:
return "email" return "email"
default:
return "retention"
} }
} }
// sendLogRetentionDays returns the effective delivery-journal retention window.
func (h *Handlers) sendLogRetentionDays() int {
days, err := h.store.GetSendLogRetentionDays(h.cfg.SendLogRetentionEnvDefault)
if err != nil {
logf("panel: send-log retention: %v", err)
if h.cfg.SendLogRetentionEnvDefault > 0 {
return h.cfg.SendLogRetentionEnvDefault
}
return store.SendLogRetentionDaysDefault
}
return days
}
@@ -0,0 +1,111 @@
package handlers
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"github.com/mixeme/selfpost/internal/dnscheck"
"github.com/mixeme/selfpost/internal/store"
"github.com/mixeme/selfpost/internal/web/auth"
"golang.org/x/crypto/bcrypt"
)
func TestSettingsPageShowsSendLogRetention(t *testing.T) {
h, _ := settingsServer(t)
if err := h.store.SetSendLogRetentionDays(45); err != nil {
t.Fatalf("SetSendLogRetentionDays: %v", err)
}
out := getBody(t, h.HandleSettings, "/settings")
for _, want := range []string{
`id="deliveries-retention"`,
`name="send_log_retention_days"`,
`value="45"`,
"Send log retention",
} {
if !strings.Contains(out, want) {
t.Errorf("settings page missing %q:\n%s", want, out)
}
}
}
func TestSubmitSettingsSavesSendLogRetention(t *testing.T) {
h, password := settingsServer(t)
values := url.Values{
"username": {"admin"},
"current_password": {password},
"send_log_retention_days": {"120"},
}
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = auth.RequestWithPrincipal(req, globalPrincipal)
rec := httptest.NewRecorder()
h.HandleSettings(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /settings = %d, want 303:\n%s", rec.Code, rec.Body.String())
}
got, err := h.store.GetSendLogRetentionDays(90)
if err != nil {
t.Fatalf("GetSendLogRetentionDays: %v", err)
}
if got != 120 {
t.Fatalf("retention = %d, want 120", got)
}
}
func TestSubmitSettingsRejectsOutOfRangeRetention(t *testing.T) {
h, password := settingsServer(t)
values := url.Values{
"username": {"admin"},
"current_password": {password},
"send_log_retention_days": {"3"},
}
req := httptest.NewRequest(http.MethodPost, "/settings", strings.NewReader(values.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req = auth.RequestWithPrincipal(req, globalPrincipal)
rec := httptest.NewRecorder()
h.HandleSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("POST /settings = %d, want 400:\n%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "between 7 and 365") {
t.Errorf("expected range error in body:\n%s", rec.Body.String())
}
}
func settingsServer(t *testing.T) (*Handlers, string) {
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() })
const password = "correct-password-here!"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if _, err := st.CreateUser("admin", string(hash), store.RoleGlobal, nil); err != nil {
t.Fatalf("create user: %v", err)
}
v := mustView(t)
a := auth.New(st, auth.Config{}, v, filepath.Join(t.TempDir(), "setup-token"))
return &Handlers{
store: st,
view: v,
auth: a,
dns: dnscheck.New(nil),
cfg: Config{SendLogRetentionEnvDefault: 90},
}, password
}
@@ -8,6 +8,10 @@
{{define "content"}} {{define "content"}}
<h1>Deliveries</h1> <h1>Deliveries</h1>
<p class="muted">Rows older than {{.SendLogRetentionDays}} days are deleted
automatically.{{if .IsGlobal}} Change retention on
<a href="/settings">Settings</a>.{{end}}</p>
<div class="card"> <div class="card">
<h2>Filter</h2> <h2>Filter</h2>
<form method="get" action="/deliveries"> <form method="get" action="/deliveries">
+16 -3
View File
@@ -77,10 +77,23 @@
</div> </div>
</div> </div>
<div class="card" id="deliveries-retention">
<h2>Deliveries</h2>
<p class="muted">How long rows on <a href="/deliveries">Deliveries</a> are
kept before the background sweep deletes them — the main driver of
<code>/data</code> growth over time. Does not affect <code>mail.log</code>
rotation (fourteen daily files).</p>
<label for="send_log_retention_days">Send log retention (days)</label>
<input id="send_log_retention_days" name="send_log_retention_days" type="number"
min="7" max="365" step="1" required
value="{{.FormSendLogRetentionDays}}">
</div>
<button type="submit">Save changes</button> <button type="submit">Save changes</button>
<p class="muted">Leave both new-password fields empty to change the username <p class="muted">Leave both new-password fields empty to change the username,
or DMARC address only. Changing the password signs out every other session; DMARC address, or send log retention only. Changing the password signs out
this one stays signed in.</p> every other session; this one stays signed in.</p>
</form> </form>
{{else}} {{else}}
<div class="card narrow"> <div class="card narrow">
+4
View File
@@ -300,11 +300,15 @@ func TestSettingsPageDocumentsRateLimits(t *testing.T) {
if !strings.Contains(src, `id="rate-limits"`) { if !strings.Contains(src, `id="rate-limits"`) {
t.Error("settings should include a sending rate limits card") t.Error("settings should include a sending rate limits card")
} }
if !strings.Contains(src, `id="deliveries-retention"`) {
t.Error("settings should include a send log retention card for global administrators")
}
for _, want := range []string{ for _, want := range []string{
"RATE_LIMIT_MESSAGES_PER_IP", "RATE_LIMIT_MESSAGES_PER_IP",
"Level 2 — domain", "Level 2 — domain",
"trusted IPs", "trusted IPs",
"{{.L1Messages}} messages / {{.L1Window}} seconds", "{{.L1Messages}} messages / {{.L1Window}} seconds",
`name="send_log_retention_days"`,
} { } {
if !strings.Contains(src, want) { if !strings.Contains(src, want) {
t.Errorf("settings rate limits card missing %q", want) t.Errorf("settings rate limits card missing %q", want)
+5 -2
View File
@@ -85,6 +85,8 @@ type Config struct {
RetryPolicy postfix.RetryPolicy RetryPolicy postfix.RetryPolicy
// InboundEnabled mirrors INBOUND_RELAY_ENABLE. // InboundEnabled mirrors INBOUND_RELAY_ENABLE.
InboundEnabled bool InboundEnabled bool
// SendLogRetentionEnvDefault is SEND_LOG_RETENTION_DAYS at panel start.
SendLogRetentionEnvDefault int
} }
// Server is the panel HTTP application. // Server is the panel HTTP application.
@@ -124,8 +126,9 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
JournalSocket: cfg.JournalSocket, JournalSocket: cfg.JournalSocket,
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP, RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
RateLimitWindowSeconds: cfg.RateLimitWindowSeconds, RateLimitWindowSeconds: cfg.RateLimitWindowSeconds,
RetryPolicy: cfg.RetryPolicy, RetryPolicy: cfg.RetryPolicy,
InboundEnabled: cfg.InboundEnabled, InboundEnabled: cfg.InboundEnabled,
SendLogRetentionEnvDefault: cfg.SendLogRetentionEnvDefault,
}, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a) }, v, dnscheck.New(cfg.DNSResolvers), &health.MachineSampler{}, a)
return &Server{cfg: cfg, auth: a, handlers: h}, nil return &Server{cfg: cfg, auth: a, handlers: h}, nil
} }