Files
email_agent/internal/apprise/apprise.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

104 lines
3.0 KiB
Go

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