package storage import ( "context" "time" ) // TimeseriesPoint holds mail count for a single day. type TimeseriesPoint struct { Day string `json:"day"` // "2026-04-05" Count int64 `json:"count"` } // MailTimeseries returns daily mail counts for the last `days` days. // Tenant-scoped when tenantID is non-nil. func (s *Store) MailTimeseries(ctx context.Context, days int, tenantID *int64) ([]TimeseriesPoint, error) { if s.db == nil { return nil, nil } if days <= 0 { days = 30 } var rows interface { Next() bool Scan(dest ...interface{}) error Close() } var err error if tenantID != nil { rows, err = s.db.Query(ctx, ` SELECT date_trunc('day', e.received_at AT TIME ZONE 'UTC')::date AS day, COUNT(*) AS cnt FROM emails e JOIN email_refs r ON r.email_id = e.id AND r.tenant_id = $2 WHERE e.received_at >= NOW() - ($1 || ' days')::interval GROUP BY day ORDER BY day ASC `, days, *tenantID) } else { rows, err = s.db.Query(ctx, ` SELECT date_trunc('day', received_at AT TIME ZONE 'UTC')::date AS day, COUNT(*) AS cnt FROM emails WHERE received_at >= NOW() - ($1 || ' days')::interval GROUP BY day ORDER BY day ASC `, days) } if err != nil { return nil, err } defer rows.Close() // Build a map day→count then fill in zeros for missing days counts := map[string]int64{} for rows.Next() { var day time.Time var cnt int64 if err := rows.Scan(&day, &cnt); err == nil { counts[day.Format("2006-01-02")] = cnt } } result := make([]TimeseriesPoint, days) for i := range result { d := time.Now().UTC().AddDate(0, 0, -(days-1-i)) key := d.Format("2006-01-02") result[i] = TimeseriesPoint{Day: key, Count: counts[key]} } return result, nil } // MailActivity holds mail counts over different time windows. type MailActivity struct { Last60Min int64 `json:"last_60_min"` Last24h int64 `json:"last_24h"` Last7d int64 `json:"last_7d"` Last30d int64 `json:"last_30d"` } // StorageEstimate holds a rough prediction of when disk space will run out. type StorageEstimate struct { AvgMailsPerDay float64 `json:"avg_mails_per_day"` AvgMailBytes int64 `json:"avg_mail_bytes"` DaysUntilFull int64 `json:"days_until_full"` // -1 = unknown/infinite ArchiveAgeDays int64 `json:"archive_age_days"` } // MailActivityStats returns how many mails arrived in the last 60min / 24h / 7d / 30d. func (s *Store) MailActivityStats(ctx context.Context) (*MailActivity, error) { if s.db == nil { return &MailActivity{}, nil } now := time.Now() windows := []time.Duration{ 60 * time.Minute, 24 * time.Hour, 7 * 24 * time.Hour, 30 * 24 * time.Hour, } counts := make([]int64, len(windows)) for i, w := range windows { cutoff := now.Add(-w) row := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM emails WHERE received_at >= $1`, cutoff) _ = row.Scan(&counts[i]) } return &MailActivity{ Last60Min: counts[0], Last24h: counts[1], Last7d: counts[2], Last30d: counts[3], }, nil } // StorageEstimateStats calculates avg mails/day and avg mail size from the DB. // freeDiskBytes: free bytes on the archive partition (passed in from caller). func (s *Store) StorageEstimateStats(ctx context.Context, freeDiskBytes uint64) (*StorageEstimate, error) { if s.db == nil { return &StorageEstimate{DaysUntilFull: -1}, nil } var totalMails, totalBytes int64 var firstReceived *time.Time if err := s.db.QueryRow(ctx, `SELECT COUNT(*), COALESCE(SUM(size_bytes), 0), MIN(received_at) FROM emails`, ).Scan(&totalMails, &totalBytes, &firstReceived); err != nil || totalMails == 0 { return &StorageEstimate{DaysUntilFull: -1}, nil } ageDays := int64(1) if firstReceived != nil { d := time.Since(*firstReceived).Hours() / 24 if d > 1 { ageDays = int64(d) } } avgMailsPerDay := float64(totalMails) / float64(ageDays) avgMailBytes := totalBytes / totalMails daysUntilFull := int64(-1) if avgMailsPerDay > 0 && avgMailBytes > 0 && freeDiskBytes > 0 { daysUntilFull = int64(float64(freeDiskBytes) / (avgMailsPerDay * float64(avgMailBytes))) } return &StorageEstimate{ AvgMailsPerDay: avgMailsPerDay, AvgMailBytes: avgMailBytes, DaysUntilFull: daysUntilFull, ArchiveAgeDays: ageDays, }, nil }