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

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