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>
This commit is contained in:
247
internal/apprise/apprise.go
Normal file
247
internal/apprise/apprise.go
Normal file
@@ -0,0 +1,247 @@
|
||||
// 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)"
|
||||
}
|
||||
Reference in New Issue
Block a user