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

224
internal/hub/ask_test.go Normal file
View File

@@ -0,0 +1,224 @@
package hub
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// fakeReader is a read-only in-memory Reader. It deliberately has no write
// method: /ask cannot change state through this interface.
type fakeReader struct {
counts []store.CategoryCount
items []store.MessageListItem
countErr error
msgErr error
countCalls int
msgCalls int
}
func (f *fakeReader) CountByCategory(_ context.Context, _ string) ([]store.CategoryCount, error) {
f.countCalls++
return f.counts, f.countErr
}
func (f *fakeReader) MessagesByCategory(_ context.Context, _ string, _ []string, _ int) ([]store.MessageListItem, error) {
f.msgCalls++
return f.items, f.msgErr
}
func fixedNow() time.Time {
return time.Date(2026, 10, 11, 8, 0, 0, 0, time.UTC)
}
func newTestAnswerer(r Reader) *Answerer {
a := NewAnswerer(r)
a.Now = fixedNow
return a
}
func TestAnswerImportant(t *testing.T) {
r := &fakeReader{
counts: []store.CategoryCount{
{Category: "Action", Count: 2},
{Category: "Important", Count: 1},
{Category: "Newsletter", Count: 40},
},
items: []store.MessageListItem{{
Message: store.Message{ID: 96, FromAddr: "Google <no-reply@accounts.google.com>", Subject: "Security alert"},
Category: "Action",
Importance: "high",
}},
}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
Question: "anything important in my inbox?",
Asker: "sam",
})
if resp.Answer == nil {
t.Fatalf("Answer = nil, reason = %q", resp.Reason)
}
if !strings.Contains(*resp.Answer, "3 emails need your attention") {
t.Errorf("answer = %q, want it to mention 3 emails", *resp.Answer)
}
if !strings.Contains(*resp.Answer, "Security alert") {
t.Errorf("answer = %q, want the top subject", *resp.Answer)
}
if got := resp.Data["count"]; got != 3 {
t.Errorf("data.count = %v, want 3", got)
}
if resp.Expires == "" {
t.Error("answered response has no expiry")
}
}
func TestAnswerImportantNone(t *testing.T) {
r := &fakeReader{counts: []store.CategoryCount{{Category: "Info", Count: 4}}}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
Question: "is anything urgent?",
Asker: "sam",
})
if resp.Answer == nil || !strings.Contains(*resp.Answer, "Nothing important") {
t.Fatalf("answer = %v, want the empty-inbox message", resp.Answer)
}
}
func TestAnswerCannotAnswerForOtherPerson(t *testing.T) {
r := &fakeReader{}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
Question: "anything important?",
Asker: "sam",
Person: "harry",
})
if resp.Answer != nil {
t.Fatalf("Answer = %v, want nil", *resp.Answer)
}
if !strings.Contains(resp.Reason, "no data") {
t.Errorf("reason = %q", resp.Reason)
}
}
func TestAnswerRequiresAsker(t *testing.T) {
r := &fakeReader{}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?"})
if resp.Answer != nil || resp.Reason == "" {
t.Fatalf("answer = %v reason = %q, want a reason", resp.Answer, resp.Reason)
}
}
func TestAnswerDoesNotTrackUnread(t *testing.T) {
r := &fakeReader{}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
Question: "how many unread emails do I have?",
Asker: "sam",
})
if resp.Answer != nil {
t.Fatalf("Answer = %v, want nil", *resp.Answer)
}
if !strings.Contains(resp.Reason, "read") {
t.Errorf("reason = %q, want it to mention read state", resp.Reason)
}
}
func TestAnswerSpamAndNewsletter(t *testing.T) {
r := &fakeReader{
counts: []store.CategoryCount{
{Category: "Spam", Count: 1},
{Category: "Newsletter", Count: 12},
},
items: []store.MessageListItem{{
Message: store.Message{ID: 5, FromAddr: "spammer@example.com", Subject: "Cheap pills"},
Category: "Spam",
}},
}
a := newTestAnswerer(r)
spam := a.Answer(context.Background(), AskRequest{Question: "any spam?", Asker: "sam"})
if spam.Answer == nil || !strings.Contains(*spam.Answer, "1 spam") {
t.Errorf("spam answer = %v", spam.Answer)
}
news := a.Answer(context.Background(), AskRequest{Question: "how many newsletters?", Asker: "sam"})
if news.Answer == nil || !strings.Contains(*news.Answer, "12 newsletters") {
t.Errorf("newsletter answer = %v", news.Answer)
}
}
func TestAnswerCountAndSummary(t *testing.T) {
r := &fakeReader{counts: []store.CategoryCount{
{Category: "Action", Count: 19},
{Category: "Important", Count: 1},
{Category: "Newsletter", Count: 40},
{Category: "Auto-reply", Count: 34},
{Category: "Info", Count: 7},
{Category: "Spam", Count: 1},
}}
a := newTestAnswerer(r)
count := a.Answer(context.Background(), AskRequest{Question: "how many emails are in my inbox?", Asker: "sam"})
if count.Answer == nil || !strings.Contains(*count.Answer, "102 emails") {
t.Errorf("count answer = %v", count.Answer)
}
sum := a.Answer(context.Background(), AskRequest{Question: "give me an overview", Asker: "sam"})
if sum.Answer == nil || !strings.Contains(*sum.Answer, "102 emails") {
t.Errorf("summary answer = %v", sum.Answer)
}
}
func TestAnswerUnknownQuestion(t *testing.T) {
r := &fakeReader{}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{
Question: "what time is it?",
Asker: "sam",
})
if resp.Answer != nil || resp.Reason == "" {
t.Fatalf("answer = %v reason = %q, want a cannot-answer reply", resp.Answer, resp.Reason)
}
}
func TestAnswerReadErrorIsReported(t *testing.T) {
r := &fakeReader{countErr: errors.New("boom")}
resp := newTestAnswerer(r).Answer(context.Background(), AskRequest{Question: "anything important?", Asker: "sam"})
if resp.Answer != nil || !strings.Contains(resp.Reason, "could not be read") {
t.Fatalf("answer = %v reason = %q", resp.Answer, resp.Reason)
}
}
func TestSenderName(t *testing.T) {
cases := map[string]string{
"Google <no-reply@accounts.google.com>": "Google",
`"api@minimaxi.com" <api@minimaxi.com>`: "api@minimaxi.com",
"<no-reply@mail.minimax.io>": "no-reply@mail.minimax.io",
"plain@example.com": "plain@example.com",
}
for in, want := range cases {
if got := senderName(in); got != want {
t.Errorf("senderName(%q) = %q, want %q", in, got, want)
}
}
}
func TestDefaultCapabilities(t *testing.T) {
c := DefaultCapabilities()
if c.Project != ProjectName {
t.Errorf("project = %q, want %q", c.Project, ProjectName)
}
for _, topic := range []string{"email", "inbox", "mail"} {
if !containsString(c.Topics, topic) {
t.Errorf("topics missing %q", topic)
}
}
if containsString(c.Topics, "unread") {
t.Error("topics must not claim read/unread support")
}
}
func containsString(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}