Files
email_agent/internal/hub/notify.go
Sam Rolfe a349b42b1f 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>
2026-10-11 22:38:49 +11:00

105 lines
2.9 KiB
Go

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