Files
selfpost/internal/store/stats.go
T
mixandClaude Opus 5.5 a618ffb0e4 send log: rows belong to a domain by id (audit L12, 0008)
A domain user's log, the message page, a domain's statistics, the level-2
count and the notifier's refusal count picked send-log rows by domain name,
so a domain deleted and re-added under its old name showed its earlier mail
to the next assignee and went on counting it.

Migration 0008 (not run on the live database yet, so extended here):
send_log.domain_id and inbound_spam_log.inbound_domain_id, REFERENCES ...
ON DELETE SET NULL, filled once by name from the registry as it is at
migration time, each with an (id, created_at) index.

- Writes: InsertQueued / InsertRejected set domain_id to the domain of the
  row's login (loginDomainID) - the journal milter and the sender check's
  refusal rows both go through them; the inbound journal sets
  inbound_domain_id from the inbound domain's name at insert.
- Reads: SendLogFilter.DomainIDs (was Domains, by name) and
  InboundSpamFilter.DomainIDs are the scope; the global role still reads
  everything. The message page checks row.DomainID against the user's
  domains. DomainSendStats and CountMessages keep their name argument and
  select domain_id = (SELECT id FROM domains WHERE name = ?). The notifier
  joins send_log.domain_id to the domain and its application.
- DMARC reports stay by name on purpose (a report is about the DNS name).

Tests: store - scope by ids; a re-added domain's log, level-2 count and
statistics hold its own row only, the global log both; inbound scope by
ids. Handlers - the new assignee gets 404 for the old row's page and does
not see it in the log, the global role sees both. Tests that wrote rows for
a login that is no application now register it (a row is a domain's through
its application). Schema at 0008 with the two columns and indexes.

schema-migrations row 8, CHANGELOG [Unreleased], audit plan line ticked,
roadmap 19/62.
Evidence: go vet ./... clean; go test ./... ok locally but for the two
Windows-only tests; the server run follows before the push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:37:26 +03:00

109 lines
3.4 KiB
Go

package store
import (
"fmt"
"math"
"time"
)
// StatsWindowDays is the rolling window for send statistics on the domain page.
const StatsWindowDays = 30
// SendStats holds message-volume metrics over a stats window (guide § Rate
// limiting — same counting rules as CountMessages).
type SendStats struct {
Total int64
PeakPerHour int64
AvgPerHour float64
WindowDays int // actual days queried (min(30, retention))
WindowHours float64 // hours used for the average-rate denominator
}
// StatsWindow computes the since timestamp and hours denominator for send
// statistics. The query window is min(30, retention) days; hours_in_window is
// min(720, entity age in hours, retention hours).
func StatsWindow(retentionDays int, entityCreated time.Time) (since time.Time, hours float64, windowDays int) {
if retentionDays <= 0 {
retentionDays = SendLogRetentionDaysDefault
}
windowDays = StatsWindowDays
if retentionDays < StatsWindowDays {
windowDays = retentionDays
}
since = time.Now().UTC().AddDate(0, 0, -windowDays)
retentionHours := float64(retentionDays) * 24
ageHours := time.Since(entityCreated).Hours()
if ageHours < 0 {
ageHours = 0
}
hours = math.Min(720, math.Min(ageHours, retentionHours))
if hours < 1 {
hours = 1
}
return since, hours, windowDays
}
// DomainSendStats returns 30-day (or shorter when retention is lower) send
// statistics for a domain, named by its name: the rows that belong to the
// domain registered under it now (audit 2026-10-12, L12).
func (s *Store) DomainSendStats(name string, retentionDays int, createdAt time.Time) (SendStats, error) {
since, hours, windowDays := StatsWindow(retentionDays, createdAt)
stats, err := s.sendStats(domainByName, name, since, hours)
if err != nil {
return SendStats{}, err
}
stats.WindowDays = windowDays
stats.WindowHours = hours
return stats, nil
}
// AppSendStats returns send statistics for an application login.
func (s *Store) AppSendStats(login string, retentionDays int, createdAt time.Time) (SendStats, error) {
since, hours, windowDays := StatsWindow(retentionDays, createdAt)
stats, err := s.sendStats("app_login = ?", login, since, hours)
if err != nil {
return SendStats{}, err
}
stats.WindowDays = windowDays
stats.WindowHours = hours
return stats, nil
}
// domainByName selects a domain's send-log rows by the id of the domain
// registered under the name it is given.
const domainByName = "domain_id = (SELECT d.id FROM domains d WHERE d.name = ?)"
// sendStats counts the rows that match, a condition with one placeholder
// taking ref.
func (s *Store) sendStats(match, ref string, since time.Time, hours float64) (SendStats, error) {
sinceStr := since.UTC().Format(time.RFC3339)
var total int64
err := s.db.QueryRow(
`SELECT COUNT(DISTINCT queue_id) FROM send_log
WHERE `+match+` AND status != ? AND created_at >= ?`,
ref, StatusRejected, sinceStr,
).Scan(&total)
if err != nil {
return SendStats{}, fmt.Errorf("send stats total %q: %w", ref, err)
}
var peak int64
err = s.db.QueryRow(
`SELECT COALESCE(MAX(bucket_count), 0) FROM (
SELECT COUNT(DISTINCT queue_id) AS bucket_count FROM send_log
WHERE `+match+` AND status != ? AND created_at >= ?
GROUP BY substr(created_at, 1, 13)
)`,
ref, StatusRejected, sinceStr,
).Scan(&peak)
if err != nil {
return SendStats{}, fmt.Errorf("send stats peak %q: %w", ref, err)
}
avg := float64(total) / hours
return SendStats{Total: total, PeakPerHour: peak, AvgPerHour: avg}, nil
}