release: 1.6.0
test / test (push) Waiting to run

Add 30-day send statistics and auto level-2 rate limits on the domain page. Close Unreleased; pin compose and docs to 1.6.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-18 22:26:51 +03:00
parent 8538b8d5f6
commit c1ec4fbd79
27 changed files with 1092 additions and 140 deletions
+5 -8
View File
@@ -147,14 +147,11 @@ func (s *Service) RateLimit(domainID int64) (store.RateLimit, bool, error) {
// SaveRateLimit stores the domain-level rate limit. The caller has validated the
// numbers (security.md); the milter reads the row live, so no reload is needed.
// Domain limits do not use an IP allowlist.
func (s *Service) SaveRateLimit(domainID int64, ips []string, maxMessages, windowSeconds int) error {
return s.store.SetRateLimit(store.RateLimit{
Scope: store.RateLimitScopeDomain,
RefID: domainID,
AllowedIPs: ips, // unused for domain enforcement; kept empty by the panel
MaxMessages: maxMessages,
WindowSeconds: windowSeconds,
})
func (s *Service) SaveRateLimit(domainID int64, rl store.RateLimit) error {
rl.Scope = store.RateLimitScopeDomain
rl.RefID = domainID
rl.AllowedIPs = nil
return s.store.SetRateLimit(rl)
}
// ClearRateLimit removes the domain-level rate limit, falling back to level 1
+72 -2
View File
@@ -25,15 +25,26 @@ type DomainExport struct {
DKIMSelector string `json:"dkim_selector"`
DKIMPrivateKey string `json:"dkim_private_key"` // PKCS#1 PEM
DMARCRua *string `json:"dmarc_rua,omitempty"` // nil = inherit profile; set = override ("" = none)
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
Applications []AppExport `json:"applications"`
}
// RateLimitExport is the transferable level-2 limit for a domain or application.
type RateLimitExport struct {
Mode string `json:"mode,omitempty"`
MaxMessages int `json:"max_messages,omitempty"`
WindowSeconds int `json:"window_seconds,omitempty"`
AutoMultiplier float64 `json:"auto_multiplier,omitempty"`
AllowedIPs []string `json:"allowed_ips,omitempty"`
}
// AppExport is one application within a DomainExport.
type AppExport struct {
Login string `json:"login"`
AddressMode string `json:"address_mode"`
Addresses []string `json:"addresses,omitempty"` // list mode only
Password string `json:"password"`
RateLimit *RateLimitExport `json:"rate_limit,omitempty"`
}
// Export builds the transferable representation of a domain: its DKIM key, its
@@ -65,17 +76,24 @@ func (s *Service) Export(id int64) (DomainExport, error) {
s := d.DMARCRua.String
exp.DMARCRua = &s
}
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeDomain, id); err == nil && ok {
exp.RateLimit = exportRateLimit(rl)
}
for _, a := range apps {
password, err := s.apps.Secret(a.Login)
if err != nil {
return DomainExport{}, fmt.Errorf("export credential for %s: %w", a.Login, err)
}
exp.Applications = append(exp.Applications, AppExport{
appExp := AppExport{
Login: a.Login,
AddressMode: a.AddressMode,
Addresses: a.Addresses,
Password: password,
})
}
if rl, ok, err := s.store.GetRateLimit(store.RateLimitScopeApp, a.ID); err == nil && ok {
appExp.RateLimit = exportRateLimit(rl)
}
exp.Applications = append(exp.Applications, appExp)
}
return exp, nil
}
@@ -123,12 +141,29 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
}
d.DMARCRua = sql.NullString{Valid: true, String: *exp.DMARCRua}
}
if exp.RateLimit != nil {
if err := s.importRateLimit(store.RateLimitScopeDomain, d.ID, *exp.RateLimit); err != nil {
s.importRollback(d.ID)
return store.Domain{}, err
}
}
for _, a := range exp.Applications {
if err := s.apps.ImportApplication(d.ID, a.Login, a.AddressMode, a.Addresses, a.Password); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import application %q: %w", a.Login, err)
}
if a.RateLimit != nil {
app, err := s.store.GetApplicationByLogin(a.Login)
if err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
}
if err := s.importRateLimit(store.RateLimitScopeApp, app.ID, *a.RateLimit); err != nil {
s.importRollback(d.ID)
return store.Domain{}, fmt.Errorf("import rate limit for %q: %w", a.Login, err)
}
}
}
if err := s.apps.Resync(); err != nil {
s.importRollback(d.ID)
@@ -145,3 +180,38 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
func (s *Service) importRollback(id int64) {
_ = s.Delete(id)
}
func exportRateLimit(rl store.RateLimit) *RateLimitExport {
mode := rl.Mode
if mode == "" {
mode = store.RateLimitModeManual
}
exp := &RateLimitExport{
Mode: mode,
MaxMessages: rl.MaxMessages,
WindowSeconds: rl.WindowSeconds,
AutoMultiplier: rl.AutoMultiplier,
AllowedIPs: rl.AllowedIPs,
}
return exp
}
func (s *Service) importRateLimit(scope string, refID int64, exp RateLimitExport) error {
mode := exp.Mode
if mode == "" {
mode = store.RateLimitModeManual
}
rl := store.RateLimit{
Scope: scope,
RefID: refID,
Mode: mode,
MaxMessages: exp.MaxMessages,
WindowSeconds: exp.WindowSeconds,
AutoMultiplier: exp.AutoMultiplier,
AllowedIPs: exp.AllowedIPs,
}
if mode == store.RateLimitModeManual && rl.MaxMessages <= 0 && rl.WindowSeconds <= 0 {
return nil
}
return s.store.SetRateLimit(rl)
}