175 lines
6.1 KiB
Go
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()
|
|
}
|