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:
98
internal/server/hub.go
Normal file
98
internal/server/hub.go
Normal 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
262
internal/server/hub_test.go
Normal 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
165
internal/server/reports.go
Normal 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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user