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>
This commit is contained in:
150
internal/summary/summary.go
Normal file
150
internal/summary/summary.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// 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
|
||||
}
|
||||
106
internal/summary/summary_test.go
Normal file
106
internal/summary/summary_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package summary
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) (*store.Store, context.Context) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "email_agent.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st, ctx
|
||||
}
|
||||
|
||||
func seed(t *testing.T, st *store.Store, ctx context.Context, gmailID, from, subject, category, importance string) {
|
||||
t.Helper()
|
||||
id, err := st.UpsertMessage(ctx, &store.Message{
|
||||
GmailID: gmailID,
|
||||
FromAddr: from,
|
||||
Subject: subject,
|
||||
DateSent: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed message: %v", err)
|
||||
}
|
||||
if _, err := st.SaveClassification(ctx, store.Classification{
|
||||
MessageID: id, Category: category, Importance: importance,
|
||||
}); err != nil {
|
||||
t.Fatalf("classify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAndText(t *testing.T) {
|
||||
st, ctx := testStore(t)
|
||||
seed(t, st, ctx, "1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
|
||||
seed(t, st, ctx, "2", "Store <shop@example.com>", "Spring sale", "Newsletter", "low")
|
||||
seed(t, st, ctx, "3", "Store <shop@example.com>", "More sale", "Newsletter", "low")
|
||||
seed(t, st, ctx, "4", "Spammer <x@example.com>", "Cheap pills", "Spam", "low")
|
||||
|
||||
b := New(st)
|
||||
b.Now = func() time.Time { return time.Date(2026, 10, 11, 8, 0, 0, 0, time.UTC) }
|
||||
data, err := b.Build(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if data.Date != "2026-10-11" {
|
||||
t.Errorf("date = %q", data.Date)
|
||||
}
|
||||
if data.InboxTotal != 4 {
|
||||
t.Errorf("InboxTotal = %d, want 4", data.InboxTotal)
|
||||
}
|
||||
if data.NeedsAttention() != 1 {
|
||||
t.Errorf("NeedsAttention = %d, want 1", data.NeedsAttention())
|
||||
}
|
||||
if data.Newsletters != 2 {
|
||||
t.Errorf("Newsletters = %d, want 2", data.Newsletters)
|
||||
}
|
||||
if data.Spam != 1 {
|
||||
t.Errorf("Spam = %d, want 1", data.Spam)
|
||||
}
|
||||
if len(data.Important) != 1 || data.Important[0].Subject != "Security alert" {
|
||||
t.Errorf("Important = %+v", data.Important)
|
||||
}
|
||||
|
||||
text := data.Text()
|
||||
for _, want := range []string{
|
||||
"daily summary for 2026-10-11",
|
||||
"Inbox: 4 message(s)",
|
||||
"Security alert",
|
||||
"Google",
|
||||
"Newsletters: 2",
|
||||
"Spam (recommend, never auto-delete): 1",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("Text() missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if sub := data.Subject(); !strings.Contains(sub, "2026-10-11") {
|
||||
t.Errorf("Subject() = %q", sub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildEmptyInbox(t *testing.T) {
|
||||
st, ctx := testStore(t)
|
||||
b := New(st)
|
||||
b.Now = func() time.Time { return time.Date(2026, 10, 11, 8, 0, 0, 0, time.UTC) }
|
||||
data, err := b.Build(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Build() error = %v", err)
|
||||
}
|
||||
if data.InboxTotal != 0 || data.NeedsAttention() != 0 {
|
||||
t.Errorf("data = %+v, want empty", data)
|
||||
}
|
||||
if !strings.Contains(data.Text(), "nothing waiting") {
|
||||
t.Errorf("Text() = %s", data.Text())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user