// Package summary builds the daily inbox summary (FR-10). // // The summary is a plain-text email body. It is built from the same read-only // classification data the triage screen uses, then stored in the `summaries` // table and delivered through Apprise. No LLM is involved: the summary must be // cheap, deterministic, and answer in under a second. package summary import ( "context" "fmt" "sort" "strings" "time" "gitea.lab.audasmedia.com.au/sam/email_agent/internal/store" ) // Builder reads the inbox and formats one day's summary. type Builder struct { Store *store.Store // Now is injectable for deterministic tests. Now func() time.Time } // New builds a Builder. func New(st *store.Store) *Builder { return &Builder{Store: st, Now: func() time.Time { return time.Now() }} } // Data is the structured summary, kept so a caller can render text or a UI. type Data struct { Date string GeneratedAt time.Time InboxTotal int Counts []store.CategoryCount Important []store.MessageListItem Newsletters int Spam int } // Build collects the summary data for the current day. func (b *Builder) Build(ctx context.Context) (Data, error) { now := b.now() counts, err := b.Store.CountByCategory(ctx, store.FolderInbox) if err != nil { return Data{}, err } important, err := b.Store.MessagesByCategory(ctx, store.FolderInbox, []string{"Action", "Important"}, 10) if err != nil { return Data{}, err } d := Data{Date: now.Format("2006-01-02"), GeneratedAt: now, Counts: counts, Important: important} for _, c := range counts { d.InboxTotal += c.Count switch strings.ToLower(strings.TrimSpace(c.Category)) { case "newsletter": d.Newsletters += c.Count case "spam": d.Spam += c.Count } } return d, nil } // Subject returns the email subject line for the summary. func (d Data) Subject() string { return fmt.Sprintf("Email Agent daily summary — %s", d.Date) } // Text renders the plain-text email body. func (d Data) Text() string { var sb strings.Builder fmt.Fprintf(&sb, "Email Agent — daily summary for %s\n\n", d.Date) fmt.Fprintf(&sb, "Inbox: %d message(s)\n", d.InboxTotal) for _, c := range sortedCounts(d.Counts) { name := c.Category if strings.TrimSpace(name) == "" { name = "Unclassified" } fmt.Fprintf(&sb, " %-12s %d\n", name, c.Count) } needs := d.NeedsAttention() fmt.Fprintf(&sb, "\nNeeds attention: %d\n", needs) if len(d.Important) == 0 { sb.WriteString(" (nothing waiting)\n") } for _, m := range d.Important { fmt.Fprintf(&sb, " - %s — %s [%s/%s]\n", strings.TrimSpace(m.Subject), senderName(m.FromAddr), m.Category, m.Importance) } fmt.Fprintf(&sb, "\nNewsletters: %d\n", d.Newsletters) fmt.Fprintf(&sb, "Spam (recommend, never auto-delete): %d\n", d.Spam) fmt.Fprintf(&sb, "\nGenerated %s by email_agent.\n", d.generatedAt()) return sb.String() } // NeedsAttention counts the Action and Important buckets. func (d Data) NeedsAttention() int { n := 0 for _, c := range d.Counts { switch strings.ToLower(strings.TrimSpace(c.Category)) { case "action", "important": n += c.Count } } return n } func (d Data) generatedAt() string { if !d.GeneratedAt.IsZero() { return d.GeneratedAt.Format("2006-01-02 15:04 MST") } return "" } func (b *Builder) now() time.Time { if b.Now == nil { return time.Now() } return b.Now() } // sortedCounts orders category rows by count descending, then name, so the text // summary is stable run to run. func sortedCounts(counts []store.CategoryCount) []store.CategoryCount { out := append([]store.CategoryCount(nil), counts...) sort.SliceStable(out, func(i, j int) bool { if out[i].Count != out[j].Count { return out[i].Count > out[j].Count } return out[i].Category < out[j].Category }) return out } // senderName extracts a display name from a From header. func senderName(addr string) string { addr = strings.TrimSpace(addr) if i := strings.Index(addr, "<"); i > 0 { name := strings.Trim(strings.TrimSpace(addr[:i]), `"'`) if name != "" { return name } } return addr }