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

103
internal/apprise/apprise.go Normal file
View File

@@ -0,0 +1,103 @@
// Package apprise delivers notifications through the house Apprise API.
//
// Apprise is the shared notification gateway (AGENTS.md §8). The Email Agent
// uses it only to deliver the daily summary by email (FR-10). The client speaks
// the standard Apprise API server shape:
//
// POST {base}/notify/{key} JSON {"title","body","type"}
// POST {base}/notify JSON {"urls","title","body","type"} (stateless)
//
// A key names a stored Apprise configuration. When no key is set the client
// uses the stateless form with an explicit list of Apprise URLs.
package apprise
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Client posts a notification to one Apprise API server.
type Client struct {
BaseURL string
// Key names a stored Apprise configuration. Empty means stateless mode.
Key string
// URLs is the stateless destination list, used when Key is empty.
URLs []string
// Token is sent as a bearer token when the Apprise API is protected.
Token string
HTTP *http.Client
}
// New builds a client. baseURL is the Apprise API root, e.g.
// http://192.168.20.35:8210. A zero timeout defaults to ten seconds.
func New(baseURL, key string, urls []string, token string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 10 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Key: strings.TrimSpace(key),
URLs: urls,
Token: token,
HTTP: &http.Client{Timeout: timeout},
}
}
// Configured reports whether a delivery can be attempted.
func (c *Client) Configured() bool {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return false
}
return c.Key != "" || len(c.URLs) > 0
}
// notifyBody is the Apprise API request body. `type` selects the notification
// class; the daily summary is informational.
type notifyBody struct {
URLs []string `json:"urls,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Type string `json:"type,omitempty"`
}
// Send delivers one notification titled title with the given text body.
func (c *Client) Send(ctx context.Context, title, body string) error {
if !c.Configured() {
return fmt.Errorf("apprise is not configured")
}
payload := notifyBody{Title: title, Body: body, Type: "info"}
url := c.BaseURL + "/notify"
if c.Key != "" {
url += "/" + c.Key
} else {
payload.URLs = c.URLs
}
raw, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("encode apprise request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return fmt.Errorf("build apprise request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("post apprise: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("post apprise: status %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,91 @@
package apprise
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type captured struct {
path string
auth string
body notifyBody
}
func TestSendWithKey(t *testing.T) {
var got captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
got.auth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&got.body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "email-summary", nil, "apprise-token", time.Second)
if err := c.Send(context.Background(), "Subject", "Body text"); err != nil {
t.Fatalf("Send() error = %v", err)
}
if got.path != "/notify/email-summary" {
t.Errorf("path = %q, want /notify/email-summary", got.path)
}
if got.auth != "Bearer apprise-token" {
t.Errorf("auth = %q", got.auth)
}
if got.body.Title != "Subject" || got.body.Body != "Body text" {
t.Errorf("body = %+v", got.body)
}
if got.body.Type != "info" {
t.Errorf("type = %q, want info", got.body.Type)
}
if len(got.body.URLs) != 0 {
t.Errorf("urls = %v, want none in key mode", got.body.URLs)
}
}
func TestSendStatelessURLs(t *testing.T) {
var got captured
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.path = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&got.body)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL, "", []string{"mailto://user:pass@example.com"}, "", time.Second)
if err := c.Send(context.Background(), "T", "B"); err != nil {
t.Fatalf("Send() error = %v", err)
}
if got.path != "/notify" {
t.Errorf("path = %q, want /notify", got.path)
}
if len(got.body.URLs) != 1 || got.body.URLs[0] != "mailto://user:pass@example.com" {
t.Errorf("urls = %v", got.body.URLs)
}
}
func TestSendErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad", http.StatusUnauthorized)
}))
defer srv.Close()
c := New(srv.URL, "key", nil, "", time.Second)
if err := c.Send(context.Background(), "T", "B"); err == nil {
t.Fatal("expected an error for a 401 reply")
}
}
func TestConfigured(t *testing.T) {
if New("http://x", "", nil, "", time.Second).Configured() {
t.Error("keyless and urlless client reports configured")
}
if New("http://x", "k", nil, "", time.Second).Configured() != true {
t.Error("key client should report configured")
}
if New("http://x", "", []string{"mailto://a@b"}, "", time.Second).Configured() != true {
t.Error("url client should report configured")
}
}

View File

@@ -13,6 +13,7 @@ import (
"os"
"strconv"
"strings"
"time"
)
// Defaults for the service. Port 8790 is recorded in AGENTS.md §9.
@@ -49,6 +50,16 @@ const (
// apps use the same API endpoint. OMNIROUTE_BASE_URL overrides it.
DefaultOmniRouteBaseURL = "http://192.168.20.13:20129/v1"
DefaultOmniRouteModel = "auto/best-chat"
// Hub defaults (Phase 7, FR-12). The central Hub runs on .13:8788. All
// non-JEV model calls and project notifications leave through it.
DefaultHubBaseURL = "http://192.168.20.13:8788"
DefaultHubNotifyTimout = 5 * time.Second
// Apprise defaults (FR-10). The house Apprise API runs on .35:8210 and
// fans out to email/NTFY/Telegram. SUMMARY_TO is the summary recipient.
DefaultAppriseURL = "http://192.168.20.35:8210"
DefaultSummaryTo = "samuelrolfe@gmail.com"
)
// Config is the fully resolved runtime configuration.
@@ -121,8 +132,27 @@ type Config struct {
// SMTPPort is the Gmail submission port (default 587, STARTTLS).
SMTPPort int
// HubToken authenticates the read-only Hub API. Secret.
// HubToken authenticates the read-only Hub API (FR-12). Secret. The Hub
// sends it as a bearer token; this service never trusts an unsigned request.
HubToken string
// HubBaseURL is the base URL of the central Hub, used by the outbound
// POST /notify path (ask-contract.md). Not secret.
HubBaseURL string
// HubNotifyTimeout bounds one outbound notify call.
HubNotifyTimeout time.Duration
// Apprise (FR-10): the daily summary is delivered by email through Apprise.
// AppriseURL is the Apprise API base URL (default the house instance on
// .35). AppriseKey names a stored Apprise configuration. AppriseURLs is the
// stateless fallback used when no key is set. AppriseToken is an optional
// bearer token for a protected Apprise API. All values come from the host
// environment; none are committed.
AppriseURL string
AppriseKey string
AppriseURLs []string
AppriseToken string
// SummaryTo is the recipient address for the daily summary email.
SummaryTo string
}
// Load reads configuration from the environment, applying defaults.
@@ -142,7 +172,14 @@ func Load() (*Config, error) {
GmailUser: os.Getenv("GMAIL_USER"),
GmailAppPassword: os.Getenv("GMAIL_APP_PASSWORD"),
HubToken: os.Getenv("HUB_TOKEN"),
HubBaseURL: strings.TrimRight(getenvDefault("HUB_BASE_URL", DefaultHubBaseURL), "/"),
AppriseURL: strings.TrimRight(getenvDefault("APPRISE_URL", DefaultAppriseURL), "/"),
AppriseKey: strings.TrimSpace(os.Getenv("APPRISE_KEY")),
AppriseURLs: splitList(os.Getenv("APPRISE_URLS")),
AppriseToken: os.Getenv("APPRISE_TOKEN"),
SummaryTo: getenvDefault("EMAIL_AGENT_SUMMARY_TO", DefaultSummaryTo),
}
c.HubNotifyTimeout = durationSetting("HUB_NOTIFY_TIMEOUT", DefaultHubNotifyTimout)
imapPort, err := resolveIMAPPort()
if err != nil {
@@ -288,3 +325,37 @@ func getenvDefault(key, def string) string {
}
return def
}
// splitList parses a comma-separated environment value, trimming blanks.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// durationSetting reads a Go duration setting, falling back to def when unset
// or malformed.
func durationSetting(key string, def time.Duration) time.Duration {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return def
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return def
}
return d
}
// HubConfigured reports whether the Hub API can serve authenticated requests.
func (c *Config) HubConfigured() bool { return strings.TrimSpace(c.HubToken) != "" }
// AppriseConfigured reports whether a daily summary can be delivered.
func (c *Config) AppriseConfigured() bool {
return strings.TrimSpace(c.AppriseURL) != "" &&
(strings.TrimSpace(c.AppriseKey) != "" || len(c.AppriseURLs) > 0)
}

View File

@@ -109,3 +109,55 @@ func TestLoadInvalidEnv(t *testing.T) {
t.Fatal("Load() error = nil, want error for invalid env")
}
}
func TestHubAndAppriseConfig(t *testing.T) {
for _, k := range []string{"HUB_TOKEN", "HUB_BASE_URL", "APPRISE_URL", "APPRISE_KEY", "APPRISE_URLS", "EMAIL_AGENT_SUMMARY_TO", "HUB_NOTIFY_TIMEOUT"} {
t.Setenv(k, "")
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.HubConfigured() {
t.Error("HubConfigured() = true with no HUB_TOKEN")
}
if cfg.HubBaseURL != DefaultHubBaseURL {
t.Errorf("HubBaseURL = %q, want %q", cfg.HubBaseURL, DefaultHubBaseURL)
}
if cfg.AppriseURL != DefaultAppriseURL {
t.Errorf("AppriseURL = %q, want %q", cfg.AppriseURL, DefaultAppriseURL)
}
if cfg.SummaryTo != DefaultSummaryTo {
t.Errorf("SummaryTo = %q, want %q", cfg.SummaryTo, DefaultSummaryTo)
}
if cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = true with no key or URLs")
}
t.Setenv("HUB_TOKEN", "hub-secret")
t.Setenv("APPRISE_KEY", "email-summary")
cfg, err = Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.HubConfigured() {
t.Error("HubConfigured() = false with HUB_TOKEN set")
}
if !cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = false with a key set")
}
// A stateless URL list also configures Apprise.
t.Setenv("APPRISE_KEY", "")
t.Setenv("APPRISE_URLS", "mailto://a:b@example.com, ntfy://topic")
cfg, err = Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.AppriseConfigured() {
t.Error("AppriseConfigured() = false with APPRISE_URLS set")
}
if len(cfg.AppriseURLs) != 2 {
t.Errorf("AppriseURLs = %v, want 2 entries", cfg.AppriseURLs)
}
}

260
internal/hub/ask.go Normal file
View File

@@ -0,0 +1,260 @@
package hub
import (
"context"
"fmt"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// ImportantCategories are the categories that mean "needs Sam's attention". They
// come from AGENTS.md §13.2 (action, important).
var ImportantCategories = []string{"Action", "Important"}
// answerer decisions. /ask is a small read-only question-answering surface, not
// an LLM call: it must answer in under three seconds and never guess.
type Answerer struct {
Reader Reader
// Now is injectable so expiry timestamps are deterministic in tests.
Now func() time.Time
}
// NewAnswerer builds an Answerer over a read-only Reader.
func NewAnswerer(r Reader) *Answerer {
return &Answerer{Reader: r, Now: func() time.Time { return time.Now().UTC() }}
}
// now returns the current time, tolerating a nil Now (as in a zero Answerer).
func (a *Answerer) now() time.Time {
if a.Now == nil {
return time.Now().UTC()
}
return a.Now()
}
// Answer returns a short, speakable answer for one question. It never writes
// state. A question it cannot answer returns answer=null plus a reason.
func (a *Answerer) Answer(ctx context.Context, req AskRequest) AskResponse {
now := a.now()
asker := strings.ToLower(strings.TrimSpace(req.Asker))
person := strings.ToLower(strings.TrimSpace(req.Person))
if person == "" {
person = asker
}
switch {
case asker == "":
return cannotAnswer("no asker was given")
case asker != "sam":
return cannotAnswer("I only answer for Sam")
case person != "sam":
return cannotAnswer("no data for that person")
}
q := normalizeQuestion(req.Question)
if q == "" {
return cannotAnswer("no question was given")
}
// The service does not track read state; say so instead of inventing one.
if containsAny(q, "unread", "not read", "mark as read") {
return cannotAnswer("I do not track read or unread mail")
}
counts, err := a.Reader.CountByCategory(ctx, "inbox")
if err != nil {
return cannotAnswer("the inbox could not be read")
}
byCategory := map[string]int{}
total := 0
for _, c := range counts {
byCategory[strings.ToLower(strings.TrimSpace(c.Category))] = c.Count
total += c.Count
}
switch {
case containsAny(q, "important", "urgent", "needs attention", "need attention",
"needs a decision", "needs a reply", "needs action", "action required",
"needs doing", "follow up", "follow-up", "to do", "todo"):
return a.answerImportant(ctx, now, byCategory)
case containsAny(q, "spam", "junk"):
return a.answerSpam(ctx, now, byCategory)
case containsAny(q, "newsletter", "unsubscribe", "subscription", "marketing", "digest"):
return a.answerNewsletter(now, byCategory)
case containsAny(q, "how many", "how much", "count", "number of"):
return a.answerCount(now, total, counts)
case containsAny(q, "summary", "overview", "what's in", "whats in", "what is in",
"anything new", "catch me up", "update me", "anything in my inbox", "anything"):
return a.answerSummary(now, total, byCategory)
default:
return cannotAnswer("I can answer questions about important mail, spam, newsletters, and inbox counts")
}
}
// answerImportant reports how many inbox messages need attention.
func (a *Answerer) answerImportant(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse {
n := byCategory["action"] + byCategory["important"]
resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)}
if n == 0 {
resp.Answer = answerText("Nothing important is waiting in your inbox.")
resp.Data = map[string]any{"count": 0}
return resp
}
items, err := a.Reader.MessagesByCategory(ctx, "inbox", ImportantCategories, 6)
if err != nil {
return cannotAnswer("the inbox could not be read")
}
switch {
case n == 1 && len(items) > 0:
resp.Answer = answerText(fmt.Sprintf("One email needs your attention: %q from %s.",
truncate(items[0].Subject, 60), senderName(items[0].FromAddr)))
case len(items) > 0:
resp.Answer = answerText(fmt.Sprintf("%d emails need your attention, including %q from %s.",
n, truncate(items[0].Subject, 60), senderName(items[0].FromAddr)))
default:
resp.Answer = answerText(fmt.Sprintf("%d emails need your attention.", n))
}
resp.Data = map[string]any{
"count": n,
"messages": messageData(items),
}
return resp
}
// answerSpam reports spam. Spam is always recommended, never auto-deleted
// (AGENTS.md §10.5), so the wording stays neutral.
func (a *Answerer) answerSpam(ctx context.Context, now time.Time, byCategory map[string]int) AskResponse {
n := byCategory["spam"]
resp := AskResponse{Expires: expiresIn(now, 10*time.Minute)}
if n == 0 {
resp.Answer = answerText("There is no spam in your inbox right now.")
resp.Data = map[string]any{"count": 0}
return resp
}
items, err := a.Reader.MessagesByCategory(ctx, "inbox", []string{"Spam"}, 6)
if err != nil {
return cannotAnswer("the inbox could not be read")
}
resp.Answer = answerText(fmt.Sprintf("%d spam emails are in your inbox.", n))
resp.Data = map[string]any{"count": n, "messages": messageData(items)}
return resp
}
func (a *Answerer) answerNewsletter(now time.Time, byCategory map[string]int) AskResponse {
n := byCategory["newsletter"]
answer := fmt.Sprintf("Your inbox has %d newsletters.", n)
if n == 1 {
answer = "Your inbox has 1 newsletter."
}
return AskResponse{
Answer: answerText(answer),
Data: map[string]any{"count": n},
Expires: expiresIn(now, 10*time.Minute),
}
}
func (a *Answerer) answerCount(now time.Time, total int, counts []store.CategoryCount) AskResponse {
return AskResponse{
Answer: answerText(fmt.Sprintf("Your inbox has %d emails.", total)),
Data: map[string]any{"total": total, "by_category": categoryMap(counts)},
Expires: expiresIn(now, 10*time.Minute),
}
}
func (a *Answerer) answerSummary(now time.Time, total int, byCategory map[string]int) AskResponse {
needs := byCategory["action"] + byCategory["important"]
news := byCategory["newsletter"]
answer := fmt.Sprintf("Your inbox has %d emails: %d need attention and %d are newsletters.",
total, needs, news)
return AskResponse{
Answer: answerText(answer),
Data: map[string]any{
"total": total,
"needs_action": needs,
"newsletters": news,
"spam": byCategory["spam"],
},
Expires: expiresIn(now, 10*time.Minute),
}
}
// messageData projects a message list into the compact `data` payload the Hub
// may ignore (ask-contract.md).
func messageData(items []store.MessageListItem) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, m := range items {
out = append(out, map[string]any{
"id": m.ID,
"from": m.FromAddr,
"subject": m.Subject,
"category": m.Category,
"importance": m.Importance,
"action": m.Action,
})
}
return out
}
// categoryMap turns the count rows into a JSON-friendly map keyed by the
// category name. The unclassified bucket uses the name "unclassified".
func categoryMap(counts []store.CategoryCount) map[string]int {
out := make(map[string]int, len(counts))
for _, c := range counts {
name := strings.ToLower(strings.TrimSpace(c.Category))
if name == "" {
name = "unclassified"
}
out[name] = c.Count
}
return out
}
// normalizeQuestion lowercases and collapses whitespace so intent matching is
// stable.
func normalizeQuestion(q string) string {
return strings.Join(strings.Fields(strings.ToLower(q)), " ")
}
func containsAny(q string, subs ...string) bool {
for _, s := range subs {
if strings.Contains(q, s) {
return true
}
}
return false
}
// senderName extracts a display name from a From header, falling back to the
// address or the raw value.
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
}
}
if i := strings.Index(addr, "<"); i >= 0 {
if j := strings.Index(addr[i:], ">"); j > i {
return strings.TrimSpace(addr[i+1 : i+j])
}
}
return addr
}
// truncate shortens s to at most n runes so a spoken answer stays short.
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if s == "" {
return "(no subject)"
}
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimSpace(string(r[:n-1])) + "…"
}

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
}

93
internal/hub/hub.go Normal file
View File

@@ -0,0 +1,93 @@
// Package hub implements the project side of the Hub contract
// (central_ai_llm_master/docs/ask-contract.md) and the outbound notify call.
//
// The contract is one-directional by design: the Hub asks a project about its
// own data and the project answers. A project never learns the Hub's data model
// and the Hub never learns the project's. The project endpoints are
// GET /capabilities and POST /ask (read only); POST /notify is the
// project-to-Hub direction.
//
// Phase 7 (FR-12). Answers are computed from SQLite; /ask performs no writes.
package hub
import (
"context"
"encoding/json"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
// ProjectName is the value the Hub matches in routing.md.
const ProjectName = "email_agent"
// Capabilities is the GET /capabilities reply. The Hub reads it to route a
// question to this project. Keep the topic list short and plain; it holds no
// private data.
type Capabilities struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
// DefaultCapabilities returns the topics this service can answer. "email",
// "inbox", "mail", and "message" match the routing.md topic map. Read/unread is
// deliberately absent: the service does not track read state.
func DefaultCapabilities() Capabilities {
return Capabilities{
Project: ProjectName,
Topics: []string{"email", "inbox", "mail", "message"},
People: []string{"sam"},
}
}
// AskRequest is the POST /ask body (ask-contract.md).
type AskRequest struct {
Question string `json:"question"`
Asker string `json:"asker"`
Person string `json:"person,omitempty"`
Source string `json:"source,omitempty"`
}
// AskResponse is the POST /ask reply. A nil Answer means the project cannot
// answer and Reason explains why. The shape is frozen by the contract.
type AskResponse struct {
Answer *string `json:"answer"`
Data map[string]any `json:"data,omitempty"`
Expires string `json:"expires,omitempty"`
Reason string `json:"reason,omitempty"`
}
// answerText is a small helper so the pointer handling stays in one place.
func answerText(s string) *string { return &s }
// cannotAnswer builds the contract's "cannot answer" reply.
func cannotAnswer(reason string) AskResponse {
return AskResponse{Answer: nil, Reason: reason}
}
// expiresIn formats an RFC3339 expiry used by data-bearing answers. An inbox
// answer goes stale quickly because mail arrives and is triaged.
func expiresIn(now time.Time, d time.Duration) string {
return now.UTC().Add(d).Format(time.RFC3339)
}
// NotifyRequest is the project-to-Hub POST /notify body (ask-contract.md). The
// Hub may use it to speak or notify.
type NotifyRequest struct {
From string `json:"from"`
Level string `json:"level"`
Message string `json:"message"`
Person string `json:"person,omitempty"`
}
// jsonBody marshals a notify payload, kept small and deterministic for tests.
func jsonBody(v any) ([]byte, error) { return json.Marshal(v) }
// Reader is the read-only data surface the answerer uses. *store.Store
// satisfies it; tests can supply a fake. Keeping it an interface makes the
// read-only guarantee explicit and easy to assert.
type Reader interface {
CountByCategory(ctx context.Context, folder string) ([]store.CategoryCount, error)
MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]store.MessageListItem, error)
}

104
internal/hub/notify.go Normal file
View File

@@ -0,0 +1,104 @@
// Outbound notify path (ask-contract.md, "Notify (project to Hub)").
//
// A project may tell the Hub something so the Hub can speak or notify. This is
// the one call this service makes into the Hub. It is best-effort: the Hub's
// /notify receiver is not part of its frozen v1 routes (docs/hub-api.md §12),
// so a failure is logged and never blocks the local work.
package hub
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"time"
)
// Notifier posts project notifications to the Hub. The nil zero value is not
// usable; build one with NewNotifier.
type Notifier struct {
BaseURL string
Token string
HTTP *http.Client
}
// NewNotifier builds a notifier for the Hub at baseURL. timeout defaults to
// five seconds when it is zero or negative.
func NewNotifier(baseURL, token string, timeout time.Duration, httpClient *http.Client) *Notifier {
if timeout <= 0 {
timeout = 5 * time.Second
}
if httpClient == nil {
httpClient = &http.Client{Timeout: timeout}
}
return &Notifier{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTP: httpClient,
}
}
// Configured reports whether the outbound notify path can be attempted.
func (n *Notifier) Configured() bool {
return n != nil && strings.TrimSpace(n.BaseURL) != ""
}
// Notify posts one notification. A nil notifier is a no-op so callers do not
// need to branch on configuration.
func (n *Notifier) Notify(ctx context.Context, req NotifyRequest) error {
if !n.Configured() {
return fmt.Errorf("hub notify is not configured")
}
if strings.TrimSpace(req.From) == "" {
req.From = ProjectName
}
body, err := jsonBody(req)
if err != nil {
return fmt.Errorf("encode notify: %w", err)
}
url := n.BaseURL + "/notify"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build notify request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
if n.Token != "" {
httpReq.Header.Set("Authorization", "Bearer "+n.Token)
}
resp, err := n.HTTP.Do(httpReq)
if err != nil {
return fmt.Errorf("post notify: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("post notify: status %d", resp.StatusCode)
}
return nil
}
// NotifySummaryReady is the convenience call used after a daily summary is
// built.
func (n *Notifier) NotifySummaryReady(ctx context.Context, message string) error {
return n.Notify(ctx, NotifyRequest{
From: ProjectName,
Level: "info",
Message: message,
Person: "sam",
})
}
// NotifyImportantCount tells the Hub how many messages need attention. It is
// used by the daily summary job when the count is non-zero.
func (n *Notifier) NotifyImportantCount(ctx context.Context, n2 int) error {
if n2 <= 0 {
return nil
}
return n.Notify(ctx, NotifyRequest{
From: ProjectName,
Level: "important",
Message: fmt.Sprintf("%d emails need a decision.", n2),
Person: "sam",
})
}

View File

@@ -0,0 +1,87 @@
package hub
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNotifyPostsContractPayload(t *testing.T) {
var (
gotReq NotifyRequest
gotAuth string
hits int
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
if r.URL.Path != "/notify" {
t.Errorf("path = %q, want /notify", r.URL.Path)
}
gotAuth = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Errorf("decode body: %v", err)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
n := NewNotifier(srv.URL, "hub-token", time.Second, nil)
if err := n.NotifyImportantCount(context.Background(), 3); err != nil {
t.Fatalf("NotifyImportantCount() error = %v", err)
}
if hits != 1 {
t.Fatalf("server hits = %d, want 1", hits)
}
if gotAuth != "Bearer hub-token" {
t.Errorf("Authorization = %q", gotAuth)
}
if gotReq.From != ProjectName {
t.Errorf("from = %q, want %q", gotReq.From, ProjectName)
}
if gotReq.Level != "important" {
t.Errorf("level = %q, want important", gotReq.Level)
}
if gotReq.Person != "sam" {
t.Errorf("person = %q, want sam", gotReq.Person)
}
if gotReq.Message != "3 emails need a decision." {
t.Errorf("message = %q", gotReq.Message)
}
}
func TestNotifyImportantCountZeroIsNoop(t *testing.T) {
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ }))
defer srv.Close()
n := NewNotifier(srv.URL, "", time.Second, nil)
if err := n.NotifyImportantCount(context.Background(), 0); err != nil {
t.Fatalf("error = %v", err)
}
if hits != 0 {
t.Errorf("server hits = %d, want 0", hits)
}
}
func TestNotifyErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "nope", http.StatusNotFound)
}))
defer srv.Close()
n := NewNotifier(srv.URL, "", time.Second, nil)
if err := n.NotifySummaryReady(context.Background(), "x"); err == nil {
t.Fatal("expected an error for a 404 reply")
}
}
func TestNotifierNotConfigured(t *testing.T) {
n := NewNotifier("", "", time.Second, nil)
if n.Configured() {
t.Error("empty notifier reports configured")
}
if err := n.NotifySummaryReady(context.Background(), "x"); err == nil {
t.Fatal("expected an error when the notifier is not configured")
}
}

98
internal/server/hub.go Normal file
View File

@@ -0,0 +1,98 @@
// Hub API for Phase 7 (FR-12).
//
// These are the project side of the frozen contract in
// central_ai_llm_master/docs/ask-contract.md:
//
// GET /capabilities -> the topics this project answers (Hub routing)
// POST /ask -> answer a question about this project's data
//
// Both endpoints require the shared bearer token. /ask is read-only: it calls
// only store read methods and writes nothing, including no actions_log row.
// This is a deliberate, testable property (see TestAskWritesNothing).
package server
import (
"context"
"crypto/subtle"
"encoding/json"
"net/http"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/hub"
)
// hubAuthRequired reports whether the Hub API has a token configured. With no
// token the endpoints refuse to serve rather than trust an unsigned request.
func (s *Server) hubAuthRequired(w http.ResponseWriter) bool {
if !s.cfg.HubConfigured() {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{
"answer": nil,
"reason": "the Hub API is not configured",
})
return false
}
return true
}
// requireHub enforces the project bearer token on the Hub endpoints.
func (s *Server) requireHub(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.hubAuthRequired(w) {
return
}
if subtle.ConstantTimeCompare([]byte(bearerToken(r)), []byte(s.cfg.HubToken)) != 1 {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"answer": nil,
"reason": "unauthorized",
})
return
}
next(w, r)
}
}
// bearerToken reads the project token from the Authorization header.
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if h == "" {
return ""
}
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
return strings.TrimSpace(h[7:])
}
return h
}
// handleCapabilities returns the routing topics for the Hub. It holds no
// private data.
func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, hub.DefaultCapabilities())
}
// handleAsk answers one question from this project's read-only data. The reply
// is always HTTP 200 with either an answer or a reason, so the Hub can relay a
// "cannot answer" without treating the project as down.
func (s *Server) handleAsk(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
var req hub.AskRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
if err := dec.Decode(&req); err != nil {
writeJSON(w, http.StatusOK, hub.AskResponse{
Answer: nil,
Reason: "the request body is not valid JSON",
})
return
}
resp := s.answerer.Answer(ctx, req)
s.logger.Debug("hub ask",
"asker", req.Asker,
"person", req.Person,
"source", req.Source,
"answered", resp.Answer != nil,
)
writeJSON(w, http.StatusOK, resp)
}

262
internal/server/hub_test.go Normal file
View File

@@ -0,0 +1,262 @@
package server
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
)
const testHubToken = "test-hub-token"
// newServerWithConfig builds a server from an explicit config so a test can set
// the Hub token and the Apprise URL.
func newServerWithConfig(t *testing.T, cfg *config.Config) *Server {
t.Helper()
st, err := store.Open(context.Background(), cfg.DBPath)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = st.Close() })
srv, err := New(cfg, st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("New() error = %v", err)
}
return srv
}
func hubTestConfig(t *testing.T) *config.Config {
t.Helper()
return &config.Config{
Env: "test",
Addr: ":0",
DBPath: filepath.Join(t.TempDir(), "email_agent.db"),
LogLevel: "error",
SessionSecret: "test-session-secret",
Password: testPassword,
HubToken: testHubToken,
HubBaseURL: "http://127.0.0.1:0",
AppriseURL: "http://127.0.0.1:0",
AppriseKey: "test-key",
SummaryTo: "sam@example.com",
}
}
func TestCapabilities(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
// Without a token the endpoint refuses.
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/capabilities", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthenticated status = %d, want 401", rec.Code)
}
req := httptest.NewRequest(http.MethodGet, "/capabilities", nil)
req.Header.Set("Authorization", "Bearer "+testHubToken)
rec = httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var body struct {
Project string `json:"project"`
Topics []string `json:"topics"`
People []string `json:"people"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Project != "email_agent" {
t.Errorf("project = %q", body.Project)
}
for _, topic := range []string{"email", "inbox", "mail"} {
if !containsString(body.Topics, topic) {
t.Errorf("topics missing %q: %v", topic, body.Topics)
}
}
}
func TestAskRequiresToken(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask", strings.NewReader(`{"question":"anything important?","asker":"sam"}`))
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
func TestAskAnswersImportant(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "a1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
seedHubMessage(t, srv.store, "a2", "Store <shop@example.com>", "Sale", "Newsletter", "low")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask",
strings.NewReader(`{"question":"anything important in my inbox?","asker":"sam"}`))
req.Header.Set("Authorization", "Bearer "+testHubToken)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", rec.Code, rec.Body.String())
}
var resp struct {
Answer *string `json:"answer"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Answer == nil || !strings.Contains(*resp.Answer, "needs your attention") {
t.Fatalf("answer = %v reason = %q", resp.Answer, resp.Reason)
}
}
// TestAskWritesNothing proves the frozen read-only property of /ask: repeated
// calls leave the message, classification, and action-log row counts unchanged.
func TestAskWritesNothing(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "w1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
seedHubMessage(t, srv.store, "w2", "Store <shop@example.com>", "Sale", "Newsletter", "low")
before := tableCounts(t, srv)
questions := []string{
`{"question":"anything important in my inbox?","asker":"sam"}`,
`{"question":"how many emails?","asker":"sam"}`,
`{"question":"any spam?","asker":"sam"}`,
`{"question":"what time is it?","asker":"sam"}`,
}
for _, body := range questions {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/ask", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+testHubToken)
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d for %s", rec.Code, body)
}
}
after := tableCounts(t, srv)
for _, table := range []string{"messages", "classifications", "actions_log", "summaries"} {
if before[table] != after[table] {
t.Errorf("%s row count changed: %d -> %d (an /ask call wrote state)", table, before[table], after[table])
}
}
}
func TestReportsGenerateStoresWithoutSending(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
seedHubMessage(t, srv.store, "r1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
sessionCookie, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/reports/generate", sessionCookie, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
summaries, err := srv.store.ListSummaries(context.Background(), 10)
if err != nil {
t.Fatalf("ListSummaries() error = %v", err)
}
if len(summaries) != 1 {
t.Fatalf("summaries = %d, want 1", len(summaries))
}
if summaries[0].DeliveredTo != "" {
t.Errorf("delivered_to = %q, want empty (generate must not send)", summaries[0].DeliveredTo)
}
}
func TestReportsSendDeliversThroughApprise(t *testing.T) {
var (
appriseHits int
apprisePath string
)
appriseSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
appriseHits++
apprisePath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer appriseSrv.Close()
cfg := hubTestConfig(t)
cfg.AppriseURL = appriseSrv.URL
cfg.AppriseKey = "summary"
srv := newServerWithConfig(t, cfg)
seedHubMessage(t, srv.store, "s1", "Google <no-reply@google.com>", "Security alert", "Action", "high")
sessionCookie, csrfCookie, csrf := login(t, srv)
rec := postForm(t, srv, "/reports/send", sessionCookie, csrfCookie, url.Values{"csrf": {csrf}})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303 (body %s)", rec.Code, rec.Body.String())
}
if appriseHits != 1 {
t.Fatalf("apprise hits = %d, want 1", appriseHits)
}
if apprisePath != "/notify/summary" {
t.Errorf("apprise path = %q, want /notify/summary", apprisePath)
}
summaries, _ := srv.store.ListSummaries(context.Background(), 10)
if len(summaries) != 1 || summaries[0].DeliveredTo != "sam@example.com" {
t.Errorf("summaries = %+v, want one delivered to sam@example.com", summaries)
}
}
func TestReportsRequireLogin(t *testing.T) {
srv := newServerWithConfig(t, hubTestConfig(t))
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/reports", nil))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want a redirect to login", rec.Code)
}
}
func seedHubMessage(t *testing.T, st *store.Store, gmailID, from, subject, category, importance string) {
t.Helper()
id, err := st.UpsertMessage(context.Background(), &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(context.Background(), store.Classification{
MessageID: id, Category: category, Importance: importance,
}); err != nil {
t.Fatalf("classify: %v", err)
}
}
// tableCounts reads row counts for the tables /ask must never touch.
func tableCounts(t *testing.T, srv *Server) map[string]int {
t.Helper()
out := map[string]int{}
for _, table := range []string{"messages", "classifications", "actions_log", "summaries"} {
var n int
if err := srv.store.DB().QueryRowContext(context.Background(),
"SELECT COUNT(*) FROM "+table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
out[table] = n
}
return out
}
func containsString(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}

165
internal/server/reports.go Normal file
View File

@@ -0,0 +1,165 @@
// Reports screen and daily-summary delivery for Phase 7 (FR-10).
//
// The Reports screen (AGENTS.md §5) lists stored daily summaries, can build
// today's summary on demand, can deliver it by email through Apprise, and can
// delete a stored report. Building never sends mail; only the explicit "Send
// now" action does, and only when Apprise is configured.
package server
import (
"net/http"
"net/url"
"strconv"
"strings"
)
type reportRow struct {
ID int64
Date string
Text string
Delivered string
CreatedAt string
}
type reportsData struct {
Summaries []reportRow
Note string
Error string
CSRFToken string
AppriseReady bool
HubReady bool
}
// handleReports renders the list of stored summaries.
func (s *Server) handleReports(w http.ResponseWriter, r *http.Request) {
s.renderReports(w, r, http.StatusOK, r.URL.Query().Get("note"), r.URL.Query().Get("error"))
}
func (s *Server) renderReports(w http.ResponseWriter, r *http.Request, code int, note, errMsg string) {
summaries, err := s.store.ListSummaries(r.Context(), 60)
if err != nil {
s.logger.Error("list summaries", "error", err)
http.Error(w, "could not load reports", http.StatusInternalServerError)
return
}
rows := make([]reportRow, 0, len(summaries))
for _, sum := range summaries {
rows = append(rows, reportRow{
ID: sum.ID,
Date: sum.Date,
Text: sum.Text,
Delivered: sum.DeliveredTo,
CreatedAt: sum.CreatedAt,
})
}
data := reportsData{
Summaries: rows,
Note: note,
Error: errMsg,
CSRFToken: s.csrfToken(w, r),
AppriseReady: s.cfg.AppriseConfigured(),
HubReady: s.cfg.HubConfigured(),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(code)
if err := s.tmpl.ExecuteTemplate(w, "reports.html", data); err != nil {
s.logger.Error("render reports", "error", err)
}
}
// handleSummaryGenerate builds and stores today's summary. It never sends mail.
func (s *Server) handleSummaryGenerate(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
data, err := s.summaries.Build(r.Context())
if err != nil {
s.logger.Error("build summary", "error", err)
s.redirectReports(w, r, "", "Could not build the summary: "+err.Error())
return
}
id, err := s.store.SaveSummary(r.Context(), data.Date, data.Text(), "")
if err != nil {
s.logger.Error("save summary", "error", err)
s.redirectReports(w, r, "", "Could not store the summary: "+err.Error())
return
}
s.logAction(r.Context(), "summary_generate", "Sam",
"date="+data.Date+" id="+strconv.FormatInt(id, 10)+
" inbox="+strconv.Itoa(data.InboxTotal)+
" needs_attention="+strconv.Itoa(data.NeedsAttention()))
s.redirectReports(w, r, "Built the summary for "+data.Date+". Nothing was sent.", "")
}
// handleSummarySend builds, stores, and emails today's summary through Apprise.
// It is the only path that sends mail, and it runs only when Sam clicks Send.
func (s *Server) handleSummarySend(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if !s.cfg.AppriseConfigured() {
s.redirectReports(w, r, "", "Apprise is not configured: set APPRISE_URL and APPRISE_KEY (or APPRISE_URLS).")
return
}
data, err := s.summaries.Build(r.Context())
if err != nil {
s.logger.Error("build summary", "error", err)
s.redirectReports(w, r, "", "Could not build the summary: "+err.Error())
return
}
id, err := s.store.SaveSummary(r.Context(), data.Date, data.Text(), "")
if err != nil {
s.logger.Error("save summary", "error", err)
s.redirectReports(w, r, "", "Could not store the summary: "+err.Error())
return
}
if err := s.apprise.Send(r.Context(), data.Subject(), data.Text()); err != nil {
s.logger.Error("deliver summary", "error", err)
s.redirectReports(w, r, "", "Apprise delivery failed: "+err.Error())
return
}
if err := s.store.MarkSummaryDelivered(r.Context(), id, s.cfg.SummaryTo); err != nil {
s.logger.Error("mark summary delivered", "error", err)
}
s.logAction(r.Context(), "summary_send", "Sam",
"date="+data.Date+" id="+strconv.FormatInt(id, 10)+" to="+s.cfg.SummaryTo)
s.redirectReports(w, r, "Sent the "+data.Date+" summary to "+s.cfg.SummaryTo+".", "")
}
// handleSummaryDelete removes one stored report (AGENTS.md §5 delete-report).
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
if !s.csrfOK(r) {
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
s.redirectReports(w, r, "", "Could not read the form.")
return
}
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
if err != nil || id < 1 {
s.redirectReports(w, r, "", "Unknown report.")
return
}
if err := s.store.DeleteSummary(r.Context(), id); err != nil {
s.logger.Error("delete summary", "error", err)
s.redirectReports(w, r, "", "Could not delete the report: "+err.Error())
return
}
s.logAction(r.Context(), "summary_delete", "Sam", "id="+strconv.FormatInt(id, 10))
s.redirectReports(w, r, "Deleted report "+strconv.FormatInt(id, 10)+".", "")
}
// redirectReports is POST/redirect/GET so a refresh does not resubmit.
func (s *Server) redirectReports(w http.ResponseWriter, r *http.Request, note, errMsg string) {
q := url.Values{}
if note != "" {
q.Set("note", note)
}
if errMsg != "" {
q.Set("error", errMsg)
}
http.Redirect(w, r, "/reports?"+q.Encode(), http.StatusSeeOther)
}

View File

@@ -17,13 +17,16 @@ import (
"net/http"
"time"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/classify"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/config"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/hub"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailfetch"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/mailops"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/reply"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/session"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/store"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/summary"
"gitea.lab.audasmedia.com.au/sam/email_agent/internal/version"
"gitea.lab.audasmedia.com.au/sam/email_agent/web"
)
@@ -46,6 +49,12 @@ type Server struct {
actions MailActions
replies ReplyGenerator
sender MailSender
// Phase 7 (FR-10/FR-12): the read-only Hub answerer, the outbound
// notifier, the daily-summary builder, and the Apprise delivery client.
answerer *hub.Answerer
notifier *hub.Notifier
summaries *summary.Builder
apprise *apprise.Client
}
// New builds a Server. It does not start listening.
@@ -82,6 +91,10 @@ func New(cfg *config.Config, st *store.Store, logger *slog.Logger) (*Server, err
reply.NewClient(cfg.OmniRouteBaseURL, cfg.OmniRouteAPIKey, cfg.OmniRouteModel, logger),
logger,
),
answerer: hub.NewAnswerer(st),
notifier: hub.NewNotifier(cfg.HubBaseURL, cfg.HubToken, cfg.HubNotifyTimeout, nil),
summaries: summary.New(st),
apprise: apprise.New(cfg.AppriseURL, cfg.AppriseKey, cfg.AppriseURLs, cfg.AppriseToken, 0),
}
s.http = &http.Server{
Addr: cfg.Addr,
@@ -161,6 +174,16 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /unsubscribe/unignore", s.requireAuth(s.handleUnsubscribeUnignore))
mux.HandleFunc("POST /unsubscribe/mark", s.requireAuth(s.handleUnsubscribeMark))
// Phase 7 (FR-12): the Hub contract. /ask is read-only and token-protected.
mux.HandleFunc("GET /capabilities", s.requireHub(s.handleCapabilities))
mux.HandleFunc("POST /ask", s.requireHub(s.handleAsk))
// Phase 7 (FR-10): daily summary and the Reports screen.
mux.HandleFunc("GET /reports", s.requireAuth(s.handleReports))
mux.HandleFunc("POST /reports/generate", s.requireAuth(s.handleSummaryGenerate))
mux.HandleFunc("POST /reports/send", s.requireAuth(s.handleSummarySend))
mux.HandleFunc("POST /reports/delete", s.requireAuth(s.handleSummaryDelete))
return s.recoverPanic(s.logRequests(mux))
}

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")
}
}

150
internal/summary/summary.go Normal file
View 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
}

View 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())
}
}