Files
email_agent/internal/summary/summary.go
Sam Rolfe a349b42b1f Phase 7: daily summary and read-only Hub API (AUD-73)
- internal/hub: project side of the frozen ask-contract. GET /capabilities,
  read-only POST /ask answerer (no LLM, <3s), and the outbound POST /notify
  client.
- internal/apprise: daily-summary delivery through the house Apprise API.
- internal/summary: deterministic plain-text daily summary builder.
- internal/store: CountByCategory, MessagesByCategory, and summary CRUD.
- internal/server: /capabilities and /ask (bearer-token protected), plus the
  Reports screen (/reports, build, send, delete).
- main.go: -summary, -summary-send, and -notify CLI entry points for n8n.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-11 22:38:49 +11:00

151 lines
4.0 KiB
Go

// 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
}