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() }