Files
mailgoserver/internal/db/crud_monitoring.go
T

175 lines
6.1 KiB
Go

package db
import "time"
// VolumeBucket is one hour's worth of esrv_email_logs, split by overall status
// (relayed/partial/failed — see relay.overallStatus).
type VolumeBucket struct {
Hour time.Time // truncated to the hour, UTC
Relayed int
Partial int
Failed int
}
// MessageVolumeByHour returns one bucket per hour for the last `hours` hours (oldest
// first), zero-filled so a quiet hour still shows as a zero-height bar rather than a
// gap in the admin dashboard's chart.
//
// Bucketing happens in Go, not SQL: esrv_email_logs.timestamp is a Go-bound
// time.Time parameter (see InsertEmailLog), which modernc.org/sqlite stores in
// RFC3339-with-nanoseconds text ("2026-08-19T18:01:39.238736245Z") — confirmed live
// that SQLite's own strftime() can't parse that format (returns NULL, silently
// matching nothing), so grouping by hour has to happen after parsing each row back
// into a time.Time in Go instead. The plain "timestamp >= ?" cutoff comparison below
// doesn't have this problem — two Go-bound values compare correctly against each
// other; it's only strftime()/date-function parsing of the stored text that breaks.
func (d *DB) MessageVolumeByHour(hours int) ([]VolumeBucket, error) {
now := time.Now().UTC()
since := now.Add(-time.Duration(hours) * time.Hour)
rows, err := d.Query(`SELECT timestamp, status FROM esrv_email_logs WHERE timestamp >= ? ORDER BY timestamp`, since)
if err != nil {
return nil, err
}
defer rows.Close()
byHour := make(map[time.Time]*VolumeBucket)
for rows.Next() {
var ts, status string
if err := rows.Scan(&ts, &status); err != nil {
return nil, err
}
t, err := parseTime(ts)
if err != nil {
continue
}
key := t.UTC().Truncate(time.Hour)
b, ok := byHour[key]
if !ok {
b = &VolumeBucket{Hour: key}
byHour[key] = b
}
switch status {
case "relayed":
b.Relayed++
case "partial":
b.Partial++
default:
b.Failed++
}
}
if err := rows.Err(); err != nil {
return nil, err
}
start := since.Truncate(time.Hour)
end := now.Truncate(time.Hour)
out := make([]VolumeBucket, 0, hours+1)
for h := start; !h.After(end); h = h.Add(time.Hour) {
if b, ok := byHour[h]; ok {
out = append(out, *b)
} else {
out = append(out, VolumeBucket{Hour: h})
}
}
return out, nil
}
// DeliveryStats totals per-recipient delivery outcomes over the last `hours` hours —
// success is a recipient actually delivered/quarantined-but-accepted; failed is
// everything else (hard rejects, relay failures).
// DeliveryStats reports success vs. failed recipient counts within the window — a
// recipient still 'queued' for the background relay-queue worker (see
// internal/relay/queue.go) counts as neither: it's not a delivery failure, it just
// hasn't been attempted yet, so including it in "failed" would understate the real
// success rate for every message still in flight.
func (d *DB) DeliveryStats(hours int) (success, failed int, err error) {
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
err = d.QueryRow(`
SELECT COALESCE(SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN r.status NOT IN ('success', 'queued') THEN 1 ELSE 0 END), 0)
FROM esrv_email_recipient_logs r
JOIN esrv_email_logs l ON l.id = r.email_log_id
WHERE l.timestamp >= ?`, since).Scan(&success, &failed)
return success, failed, err
}
// DomainSendCount is one sending domain's message count within a window.
type DomainSendCount struct {
Domain string
Count int
}
// SendCountsByDomain returns the busiest sending domains (by envelope MAIL FROM) over
// the last `hours` hours, most first.
func (d *DB) SendCountsByDomain(hours int) ([]DomainSendCount, error) {
since := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
rows, err := d.Query(`
SELECT substr(mail_from, instr(mail_from, '@') + 1) AS domain, COUNT(*) AS n
FROM esrv_email_logs
WHERE timestamp >= ? AND instr(mail_from, '@') > 0
GROUP BY domain
ORDER BY n DESC
LIMIT 20`, since)
if err != nil {
return nil, err
}
defer rows.Close()
var out []DomainSendCount
for rows.Next() {
var c DomainSendCount
if err := rows.Scan(&c.Domain, &c.Count); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// CountRecentSendsForDomain counts esrv_email_logs rows from domain since the given
// cutoff — backs Session.domainSendRateLimited's per-hour outbound cap. Counts every
// logged DATA transaction from the domain (local delivery included, not just external
// relay) — a deliberate scope simplification: esrv_email_logs doesn't distinguish
// local-only from relay-included rows, and a compromised account's send burst shows up
// in this count either way.
func (d *DB) CountRecentSendsForDomain(domain string, since time.Time) (int, error) {
var n int
// mail_from_domain is computed once at insert time (see InsertEmailLog) and
// indexed, rather than recomputing substr/instr per row here on every relay send —
// SQLite can't use an index for a computed-expression WHERE clause, so the old form
// of this query was a full table scan against ever-growing history.
err := d.QueryRow(`SELECT COUNT(*) FROM esrv_email_logs
WHERE mail_from_domain = ? AND timestamp >= ?`,
domain, since.UTC()).Scan(&n)
return n, err
}
// MailboxUsage is one mailbox's current quota consumption — a snapshot, not a
// historical trend (no periodic usage snapshots are collected; building that
// collection job is out of scope for what this admin view needs).
type MailboxUsage struct {
Email string
UsedBytes int64
QuotaBytes int64
}
// TopMailboxesByQuotaUsage returns the mailboxes closest to their quota, fullest
// first.
func (d *DB) TopMailboxesByQuotaUsage(limit int) ([]MailboxUsage, error) {
rows, err := d.Query(`SELECT email, used_bytes, quota_bytes FROM esrv_mailboxes
WHERE quota_bytes > 0 ORDER BY (CAST(used_bytes AS REAL) / quota_bytes) DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []MailboxUsage
for rows.Next() {
var m MailboxUsage
if err := rows.Scan(&m.Email, &m.UsedBytes, &m.QuotaBytes); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}