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:
2026-10-11 22:38:49 +11:00
parent c4213ebed1
commit a349b42b1f
24 changed files with 2454 additions and 1 deletions

217
internal/store/summary.go Normal file
View File

@@ -0,0 +1,217 @@
// Summary and reporting persistence for Phase 7 (FR-10).
//
// The daily summary is built from the same classification data the triage list
// uses; it is stored once per date in the `summaries` table (AGENTS.md §7) and
// then delivered by email through Apprise.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
)
// CategoryCount is one bucket of the inbox overview: a category name and how
// many messages currently sit in it. The empty category is the unclassified
// bucket.
type CategoryCount struct {
Category string
Count int
}
// CountByCategory returns message counts for one folder, grouped by each
// message's latest classification category. Messages with no usable
// classification are counted under the empty category name.
func (s *Store) CountByCategory(ctx context.Context, folder string) ([]CategoryCount, error) {
const q = `
SELECT COALESCE(c.category, ''), COUNT(*)
FROM messages m
LEFT JOIN classifications c ON c.id = (
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
)
WHERE (? = '' OR m.folder = ?)
GROUP BY 1
ORDER BY 2 DESC, 1 ASC`
rows, err := s.db.QueryContext(ctx, q, folder, folder)
if err != nil {
return nil, fmt.Errorf("count by category: %w", err)
}
defer rows.Close()
var out []CategoryCount
for rows.Next() {
var c CategoryCount
if err := rows.Scan(&c.Category, &c.Count); err != nil {
return nil, fmt.Errorf("scan category count: %w", err)
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("count by category: %w", err)
}
return out, nil
}
// MessagesByCategory returns the newest messages in a folder whose latest
// classification is one of categories, highest importance first. It backs the
// "what needs attention" list in the daily summary and the Hub answer.
func (s *Store) MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]MessageListItem, error) {
if len(categories) == 0 {
return nil, nil
}
if limit < 1 {
limit = 20
}
placeholders := make([]string, len(categories))
args := make([]any, 0, len(categories)+2)
for i, c := range categories {
placeholders[i] = "?"
args = append(args, c)
}
// The importance rank puts high first; an unset importance sorts last.
rank := `CASE LOWER(COALESCE(c.importance, ''))
WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END`
folderClause := ""
if folder != "" {
folderClause = "AND m.folder = ?"
args = append(args, folder)
}
args = append(args, limit)
q := `
SELECT
m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''),
COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''),
COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at,
COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0),
COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'),
COALESCE(c.category, ''), COALESCE(c.importance, ''),
COALESCE(c.action, ''), COALESCE(c.confidence, 0)
FROM messages m
LEFT JOIN classifications c ON c.id = (
SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1
)
WHERE c.category IN (` + strings.Join(placeholders, ",") + `) ` + folderClause + `
ORDER BY ` + rank + `, COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC
LIMIT ?`
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("messages by category: %w", err)
}
defer rows.Close()
var out []MessageListItem
for rows.Next() {
var (
item MessageListItem
dateSent string
received string
)
if err := rows.Scan(
&item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr,
&item.ToAddr, &item.Subject, &item.BodyText,
&item.SourceAddress, &dateSent, &received,
&item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Mailbox,
&item.Category, &item.Importance, &item.Action, &item.Confidence,
); err != nil {
return nil, fmt.Errorf("scan category message: %w", err)
}
item.DateSent = parseSQLTime(dateSent)
item.ReceivedAt = parseSQLTime(received)
item.HasClassification = item.Category != ""
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("messages by category: %w", err)
}
return out, nil
}
// Summary is one stored daily summary (FR-10).
type Summary struct {
ID int64
Date string
Text string
DeliveredTo string
CreatedAt string
}
// SaveSummary stores the summary for a date, replacing an earlier build for the
// same date so re-running the job does not duplicate rows. It returns the row id.
func (s *Store) SaveSummary(ctx context.Context, date, text, deliveredTo string) (int64, error) {
const q = `
INSERT INTO summaries (date, text, delivered_to) VALUES (?, ?, ?)
ON CONFLICT (date) DO UPDATE SET
text = excluded.text,
delivered_to = excluded.delivered_to`
if _, err := s.db.ExecContext(ctx, q, date, text, nullString(deliveredTo)); err != nil {
return 0, fmt.Errorf("save summary %s: %w", date, err)
}
var id int64
if err := s.db.QueryRowContext(ctx,
`SELECT id FROM summaries WHERE date = ?`, date).Scan(&id); err != nil {
return 0, fmt.Errorf("read summary id for %s: %w", date, err)
}
return id, nil
}
// MarkSummaryDelivered records the recipient once a summary has been emailed.
func (s *Store) MarkSummaryDelivered(ctx context.Context, id int64, deliveredTo string) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE summaries SET delivered_to = ? WHERE id = ?`, nullString(deliveredTo), id); err != nil {
return fmt.Errorf("mark summary %d delivered: %w", id, err)
}
return nil
}
// SummaryByDate loads one summary by its YYYY-MM-DD date. ok is false when none
// exists.
func (s *Store) SummaryByDate(ctx context.Context, date string) (Summary, bool, error) {
row := s.db.QueryRowContext(ctx, `
SELECT id, date, text, COALESCE(delivered_to, ''), created_at
FROM summaries WHERE date = ?`, date)
var sum Summary
if err := row.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Summary{}, false, nil
}
return Summary{}, false, fmt.Errorf("read summary %s: %w", date, err)
}
return sum, true, nil
}
// ListSummaries returns stored summaries, newest date first.
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
if limit < 1 {
limit = 30
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, date, text, COALESCE(delivered_to, ''), created_at
FROM summaries ORDER BY date DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list summaries: %w", err)
}
defer rows.Close()
var out []Summary
for rows.Next() {
var sum Summary
if err := rows.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil {
return nil, fmt.Errorf("scan summary: %w", err)
}
out = append(out, sum)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list summaries: %w", err)
}
return out, nil
}
// DeleteSummary removes one stored report (AGENTS.md §5 "delete-report").
func (s *Store) DeleteSummary(ctx context.Context, id int64) error {
if _, err := s.db.ExecContext(ctx, `DELETE FROM summaries WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete summary %d: %w", id, err)
}
return nil
}

View File

@@ -0,0 +1,106 @@
package store
import (
"context"
"testing"
)
func TestCountByCategory(t *testing.T) {
s, ctx := testStore(t)
a := seedMessage(t, s, ctx, "m1", "Security alert", "hello@where-woof.com")
b := seedMessage(t, s, ctx, "m2", "News", "hello@where-woof.com")
if _, err := s.SaveClassification(ctx, Classification{MessageID: a, Category: "Action", Importance: "high"}); err != nil {
t.Fatalf("classify a: %v", err)
}
if _, err := s.SaveClassification(ctx, Classification{MessageID: b, Category: "Newsletter"}); err != nil {
t.Fatalf("classify b: %v", err)
}
counts, err := s.CountByCategory(ctx, FolderInbox)
if err != nil {
t.Fatalf("CountByCategory() error = %v", err)
}
got := map[string]int{}
for _, c := range counts {
got[c.Category] = c.Count
}
if got["Action"] != 1 || got["Newsletter"] != 1 {
t.Errorf("counts = %v, want Action=1 Newsletter=1", got)
}
}
func TestMessagesByCategoryOrder(t *testing.T) {
s, ctx := testStore(t)
low := seedMessage(t, s, ctx, "low", "Routine", "hello@where-woof.com")
high := seedMessage(t, s, ctx, "high", "Urgent", "hello@where-woof.com")
info := seedMessage(t, s, ctx, "info", "FYI", "hello@where-woof.com")
mustClassify(t, s, ctx, low, "Action", "low")
mustClassify(t, s, ctx, high, "Action", "high")
mustClassify(t, s, ctx, info, "Info", "low")
items, err := s.MessagesByCategory(ctx, FolderInbox, []string{"Action", "Important"}, 10)
if err != nil {
t.Fatalf("MessagesByCategory() error = %v", err)
}
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
if items[0].ID != high {
t.Errorf("first item id = %d, want the high-importance message %d", items[0].ID, high)
}
}
func mustClassify(t *testing.T, s *Store, ctx context.Context, msgID int64, category, importance string) {
t.Helper()
if _, err := s.SaveClassification(ctx, Classification{MessageID: msgID, Category: category, Importance: importance}); err != nil {
t.Fatalf("classify %d: %v", msgID, err)
}
}
func TestSummaryStore(t *testing.T) {
s, ctx := testStore(t)
id, err := s.SaveSummary(ctx, "2026-10-11", "first text", "")
if err != nil {
t.Fatalf("SaveSummary() error = %v", err)
}
// Saving the same date replaces rather than duplicates.
id2, err := s.SaveSummary(ctx, "2026-10-11", "second text", "sam@example.com")
if err != nil {
t.Fatalf("SaveSummary() second error = %v", err)
}
if id2 != id {
t.Errorf("id = %d, want the existing row %d", id2, id)
}
sum, ok, err := s.SummaryByDate(ctx, "2026-10-11")
if err != nil || !ok {
t.Fatalf("SummaryByDate() ok=%v err=%v", ok, err)
}
if sum.Text != "second text" || sum.DeliveredTo != "sam@example.com" {
t.Errorf("summary = %+v", sum)
}
list, err := s.ListSummaries(ctx, 10)
if err != nil {
t.Fatalf("ListSummaries() error = %v", err)
}
if len(list) != 1 {
t.Fatalf("list = %d, want 1", len(list))
}
if err := s.MarkSummaryDelivered(ctx, id, "other@example.com"); err != nil {
t.Fatalf("MarkSummaryDelivered() error = %v", err)
}
sum, _, _ = s.SummaryByDate(ctx, "2026-10-11")
if sum.DeliveredTo != "other@example.com" {
t.Errorf("delivered_to = %q", sum.DeliveredTo)
}
if err := s.DeleteSummary(ctx, id); err != nil {
t.Fatalf("DeleteSummary() error = %v", err)
}
if _, ok, _ := s.SummaryByDate(ctx, "2026-10-11"); ok {
t.Error("summary still present after delete")
}
}