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>
248 lines
7.9 KiB
Go
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)"
|
|
}
|