Files
Sam Rolfe 09639c5125 Phase 7: notify and send via Apprise (Email + WhatsApp)
The morning briefs go out through the house Apprise API on `.35:8210`: one
brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service
names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an
address, a phone number or an SMTP password (AGENTS.md §10 rule 3).

- internal/apprise: keyed and stateless client, tag filtering, and error
  redaction so nothing sensitive reaches a log, a row or a screen.
- internal/brief: builds the per-boy and the combined brief, delivers on email
  and WhatsApp, records every attempt in `notifications` (migration 004 adds
  scope, channel, tag, ok and error) and fails loudly.
- Send panel (§5 screen 4): recipients, channels, an exact preview, Send now,
  plus the Send action on a stored draft (FR-10). The dashboard shows a
  Morning briefs banner and the Sends list.
- POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule
  calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring
  without messaging the family.
- scripts/morning-brief.sh: scan both children, then send. It is the n8n job;
  deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily).
- FR-12: a failed scan is never "nothing due" -- the brief says so, and the run
  exits non-zero. A failed delivery is recorded with no sent_at.
- The long no-due-date tail is capped in the sent brief so it stays brief
  (§10 rule 6); overdue work is never capped.

Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and
sent_at, and the endpoint answers 401/200/207 as documented. Evidence in
docs/phase-7-notify-notes.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:28:05 +11:00

248 lines
7.9 KiB
Go

// Package apprise delivers notifications through the house Apprise API on
// `.35:8210` (AGENTS.md §8). Phase 7 uses it for the morning briefs: one brief
// per boy, plus one combined brief to Jo and Sam (FR-9, FR-14).
//
// The Apprise API holds every destination in a stored configuration, selected
// by tag. The school service therefore never holds an email address, a phone
// number or an SMTP password: it only names a tag such as "harry_email"
// (AGENTS.md §10 rule 3). The API shape it speaks is:
//
// POST {base}/notify/{key} JSON {"tag":["harry_email"],"title","body","type"}
// POST {base}/notify JSON {"urls":[...],"title","body","type"} (stateless)
//
// A failure is returned as an error with the redacted server text, so the
// caller can record it and show it loudly (FR-12). Nothing is retried here.
package apprise
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Notification classes understood by Apprise.
const (
TypeInfo = "info"
TypeSuccess = "success"
TypeWarning = "warning"
TypeFailure = "failure"
)
// Result is the outcome of one delivery attempt.
type Result struct {
// Status is the HTTP status the Apprise API returned (0 when the request
// never completed).
Status int
// Body is the redacted response text, kept short for the log.
Body string
// Duration is how long the attempt took.
Duration time.Duration
}
// OK reports whether Apprise accepted the notification for every service.
func (r Result) OK() bool { return r.Status >= 200 && r.Status < 300 }
// Client posts notifications to one Apprise API server.
type Client struct {
// BaseURL is the Apprise API root, for example http://192.168.20.35:8210.
BaseURL string
// Key names a stored Apprise configuration. Empty means stateless mode,
// which requires URLs on every send.
Key string
// Token is sent as a bearer token when the Apprise API is protected.
Token string
// HTTP is the transport. A nil value means a client with a 15s timeout.
HTTP *http.Client
}
// New builds a client. A zero timeout defaults to fifteen seconds: the morning
// brief waits on a real SMTP or WhatsApp gateway, so a short timeout would
// report a false failure.
func New(baseURL, key, token string, timeout time.Duration) *Client {
if timeout <= 0 {
timeout = 15 * time.Second
}
return &Client{
BaseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
Key: strings.TrimSpace(key),
Token: strings.TrimSpace(token),
HTTP: &http.Client{Timeout: timeout},
}
}
// Configured reports whether a delivery can be attempted. A nil client is not
// configured, so callers never branch on nil.
func (c *Client) Configured() bool {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return false
}
return true // the base URL is set; a key or explicit URLs are checked on send
}
// notifyBody is the Apprise API request body. `tag` selects a subset of the
// stored configuration; `urls` is the stateless alternative.
type notifyBody struct {
URLs []string `json:"urls,omitempty"`
Tag []string `json:"tag,omitempty"`
Title string `json:"title,omitempty"`
Body string `json:"body"`
Type string `json:"type,omitempty"`
}
// SendTags delivers one notification to every stored destination that carries
// one of the given tags. An empty tag list is refused: sending the whole stored
// configuration would hit destinations that are configured for other purposes
// (and the empty WhatsApp placeholders), so the failure would be ambiguous.
func (c *Client) SendTags(ctx context.Context, tags []string, title, body, notifyType string) (Result, error) {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
}
if c.Key == "" {
return Result{}, fmt.Errorf("apprise key is not set: set SCHOOL_APPRISE_KEY")
}
clean := make([]string, 0, len(tags))
for _, t := range tags {
if t = strings.TrimSpace(t); t != "" {
clean = append(clean, t)
}
}
if len(clean) == 0 {
return Result{}, fmt.Errorf("apprise tag is empty: nothing would be delivered")
}
return c.post(ctx, c.BaseURL+"/notify/"+c.Key, notifyBody{Tag: clean, Title: title, Body: body, Type: notifyType})
}
// SendURLs delivers one notification to an explicit stateless destination list.
// It is the fallback when no stored configuration is set.
func (c *Client) SendURLs(ctx context.Context, urls []string, title, body, notifyType string) (Result, error) {
if c == nil || strings.TrimSpace(c.BaseURL) == "" {
return Result{}, fmt.Errorf("apprise is not configured: set SCHOOL_APPRISE_URL")
}
if len(urls) == 0 {
return Result{}, fmt.Errorf("apprise destination list is empty")
}
return c.post(ctx, c.BaseURL+"/notify", notifyBody{URLs: urls, Title: title, Body: body, Type: notifyType})
}
func (c *Client) post(ctx context.Context, url string, payload notifyBody) (Result, error) {
if payload.Type == "" {
payload.Type = TypeInfo
}
raw, err := json.Marshal(payload)
if err != nil {
return Result{}, fmt.Errorf("encode apprise request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
return Result{}, fmt.Errorf("build apprise request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
httpClient := c.HTTP
if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second}
}
started := time.Now()
resp, err := httpClient.Do(req)
if err != nil {
// A transport error is wrapped by net/http; it never echoes a secret.
return Result{Duration: time.Since(started)}, fmt.Errorf("post apprise: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
res := Result{Status: resp.StatusCode, Body: Truncate(Redact(strings.TrimSpace(string(body))), 300), Duration: time.Since(started)}
if !res.OK() {
msg := fmt.Sprintf("apprise returned status %d", resp.StatusCode)
if res.Body != "" {
msg += ": " + res.Body
}
return res, fmt.Errorf("post apprise: %s", msg)
}
return res, nil
}
// Redact removes anything that must never reach a log, a database row or a
// screen: email addresses, phone numbers, Apprise keys and long tokens.
// Apprise echoes the failing destination in its error text, so this runs on
// every response before the text is stored (AGENTS.md §10 rule 3).
func Redact(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); {
c := s[i]
if isEmailStart(c) {
if j := emailEnd(s, i); j > i {
b.WriteString("<redacted-address>")
i = j
continue
}
}
if isDigit(c) {
// A run of seven or more digits is a phone number or a token.
j := i
for j < len(s) && (isDigit(s[j]) || s[j] == '-' || s[j] == ' ') && j-i < 24 {
j++
}
run := strings.NewReplacer("-", "", " ", "").Replace(s[i:j])
if len(run) >= 7 {
b.WriteString("<redacted-number>")
i = j
continue
}
}
b.WriteByte(c)
i++
}
return b.String()
}
func isEmailStart(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || isDigit(c)
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// emailEnd returns the index just past an address starting at i, or i when the
// text at i is not an address.
func emailEnd(s string, i int) int {
j := i
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '_' || s[j] == '%' || s[j] == '+' || s[j] == '-') {
j++
}
if j >= len(s) || s[j] != '@' {
return i
}
j++
host := j
for j < len(s) && (isEmailStart(s[j]) || s[j] == '.' || s[j] == '-') {
j++
}
if j == host || !strings.Contains(s[host:j], ".") {
return i
}
return j
}
// Truncate shortens s to at most n bytes, marking that it was cut.
func Truncate(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
cut := s[:n]
if i := strings.LastIndexAny(cut, " \n"); i > n/2 {
cut = cut[:i]
}
return cut + "… (truncated)"
}