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:
2026-10-15 19:28:01 +11:00
parent da57efad73
commit 09639c5125
31 changed files with 4266 additions and 169 deletions

View File

@@ -0,0 +1,127 @@
package config
import (
"strings"
"testing"
"time"
)
// clearAppriseEnv removes every Apprise variable so a load starts from the
// defaults, not from the developer's environment.
func clearAppriseEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
"SCHOOL_APPRISE_URL", "SCHOOL_APPRISE_KEY", "SCHOOL_APPRISE_TOKEN",
"SCHOOL_APPRISE_TIMEOUT", "SCHOOL_APPRISE_CHANNELS", "SCHOOL_APPRISE_TAGS",
"SCHOOL_APPRISE_PARENTS", "SCHOOL_NOTIFY_TOKEN",
} {
t.Setenv(k, "")
}
}
func TestLoadAppriseDefaults(t *testing.T) {
clearAppriseEnv(t)
cfg, err := loadApprise()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.BaseURL != DefaultAppriseURL {
t.Errorf("url = %q, want %q", cfg.BaseURL, DefaultAppriseURL)
}
if cfg.Enabled() {
t.Error("Enabled = true without a key")
}
if got := strings.Join(cfg.Missing(), ","); got != "SCHOOL_APPRISE_KEY" {
t.Errorf("missing = %q", got)
}
if cfg.Timeout != 15*time.Second {
t.Errorf("timeout = %v", cfg.Timeout)
}
if len(cfg.Channels) != 2 || cfg.Channels[0] != ChannelEmail || cfg.Channels[1] != ChannelWhatsApp {
t.Errorf("channels = %#v", cfg.Channels)
}
// The family recipients are the default, so a missing env value cannot
// silently send the briefs nowhere.
names := make([]string, 0, len(cfg.Recipients))
for _, r := range cfg.Recipients {
names = append(names, r.Name+"="+r.TagBase)
}
if got := strings.Join(names, ","); got != DefaultAppriseTags {
t.Errorf("recipients = %q, want %q", got, DefaultAppriseTags)
}
harry, ok := cfg.RecipientByName("harry")
if !ok {
t.Fatal("Harry is not configured")
}
if got := cfg.ChannelTag(harry, ChannelWhatsApp); got != "harry_whatsapp" {
t.Errorf("whatsapp tag = %q", got)
}
if got := cfg.ChannelTag(harry, ChannelEmail); got != "harry_email" {
t.Errorf("email tag = %q", got)
}
if got := cfg.ParentRecipients(); len(got) != 2 || got[0].Name != "Jo" || got[1].Name != "Sam" {
t.Errorf("parents = %#v", got)
}
if cfg.IsParent("Finn") {
t.Error("Finn is not a parent")
}
}
func TestLoadAppriseCustom(t *testing.T) {
clearAppriseEnv(t)
t.Setenv("SCHOOL_APPRISE_URL", "http://example.test:9000/")
t.Setenv("SCHOOL_APPRISE_KEY", "abc")
t.Setenv("SCHOOL_APPRISE_TIMEOUT", "3s")
t.Setenv("SCHOOL_APPRISE_CHANNELS", "email")
t.Setenv("SCHOOL_APPRISE_TAGS", "Harry=h")
t.Setenv("SCHOOL_APPRISE_PARENTS", "Harry")
t.Setenv("SCHOOL_NOTIFY_TOKEN", "n8n-token")
cfg, err := loadApprise()
if err != nil {
t.Fatalf("load: %v", err)
}
if !cfg.Enabled() {
t.Error("Enabled = false with a key")
}
if cfg.BaseURL != "http://example.test:9000" {
t.Errorf("url = %q", cfg.BaseURL)
}
if cfg.Timeout != 3*time.Second {
t.Errorf("timeout = %v", cfg.Timeout)
}
if len(cfg.Channels) != 1 || cfg.Channels[0] != ChannelEmail {
t.Errorf("channels = %#v", cfg.Channels)
}
if cfg.NotifyToken != "n8n-token" {
t.Errorf("notify token = %q", cfg.NotifyToken)
}
}
// TestLoadAppriseRejectsBadInput proves a typo fails at start-up instead of at
// 6:00am, when nobody is watching (FR-12).
func TestLoadAppriseRejectsBadInput(t *testing.T) {
cases := map[string]func(t *testing.T){
"unknown channel": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_CHANNELS", "sms") },
"unknown parent": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_PARENTS", "Nana") },
"bad tag entry": func(t *testing.T) { t.Setenv("SCHOOL_APPRISE_TAGS", "Harry") },
}
for name, set := range cases {
t.Run(name, func(t *testing.T) {
clearAppriseEnv(t)
set(t)
if _, err := loadApprise(); err == nil {
t.Fatal("want an error")
}
})
}
}
func TestValidChannels(t *testing.T) {
c := AppriseConfig{Channels: []string{ChannelEmail, "sms", ChannelWhatsApp}}
ok, bad := c.ValidChannels()
if len(ok) != 2 || len(bad) != 1 || bad[0] != "sms" {
t.Errorf("ok = %#v bad = %#v", ok, bad)
}
}

View File

@@ -46,6 +46,9 @@ type Config struct {
// Calendar is the Home Assistant calendar target for important school
// events (AGENTS.md §4 FR-8).
Calendar CalendarConfig
// Apprise is the notification gateway for the morning briefs (FR-9,
// FR-14).
Apprise AppriseConfig
}
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes
@@ -91,6 +94,137 @@ func (c CalendarConfig) Missing() []string {
return out
}
// AppriseConfig points at the house Apprise API on `.35:8210` (AGENTS.md §8)
// and names the destinations by tag. The school service never holds an email
// address, a phone number or an SMTP password; the Apprise API holds those and
// selects them by tag (AGENTS.md §10 rule 3).
//
// Environment variables:
//
// SCHOOL_APPRISE_URL API base, default http://192.168.20.35:8210
// SCHOOL_APPRISE_KEY stored configuration key (required to send)
// SCHOOL_APPRISE_TOKEN optional bearer token
// SCHOOL_APPRISE_TIMEOUT per-request timeout, default 15s
// SCHOOL_APPRISE_CHANNELS channels to use, default "email,whatsapp"
// SCHOOL_APPRISE_TAGS recipient=tag-base list, default
// "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// SCHOOL_APPRISE_PARENTS who gets the combined brief, default "Jo,Sam"
// SCHOOL_NOTIFY_TOKEN bearer token for POST /notify/morning (n8n)
//
// A channel tag is the recipient's tag base plus "_email" or "_whatsapp",
// which is the naming the house Apprise configuration already uses.
type AppriseConfig struct {
BaseURL string
// Key names the stored Apprise configuration that holds every destination.
Key string
// Token is an optional bearer token when the Apprise API is protected.
Token string
// Timeout bounds one delivery attempt.
Timeout time.Duration
// Channels are the channels the morning brief uses, in order. Each entry is
// "email" or "whatsapp".
Channels []string
// Recipients are the people a brief can be sent to, in order.
Recipients []Recipient
// Parents are the recipient names that receive the combined brief.
Parents []string
// NotifyToken guards POST /notify/morning, the endpoint n8n calls.
NotifyToken string
}
// Recipient is one person a brief can go to. TagBase is the Apprise tag prefix,
// for example "harry" for the tags harry_email and harry_whatsapp.
type Recipient struct {
Name string
TagBase string
}
// The channels Phase 7 delivers on (AGENTS.md §13 decision 8).
const (
ChannelEmail = "email"
ChannelWhatsApp = "whatsapp"
)
// DefaultAppriseURL is the house Apprise API on `.35` (AGENTS.md §2a, §8).
const DefaultAppriseURL = "http://192.168.20.35:8210"
// DefaultAppriseTags is the recipient list already present in the house
// Apprise configuration: each boy, and both parents.
const DefaultAppriseTags = "Harry=harry,Finn=finn,Jo=joanna,Sam=sam"
// DefaultAppriseParents is who receives the combined brief (FR-14).
const DefaultAppriseParents = "Jo,Sam"
// Enabled reports whether a brief can be delivered at all.
func (c AppriseConfig) Enabled() bool {
return c.BaseURL != "" && c.Key != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the gateway is usable.
func (c AppriseConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_APPRISE_URL")
}
if c.Key == "" {
out = append(out, "SCHOOL_APPRISE_KEY")
}
return out
}
// RecipientByName returns the configured recipient with the given name.
func (c AppriseConfig) RecipientByName(name string) (Recipient, bool) {
for _, r := range c.Recipients {
if strings.EqualFold(r.Name, strings.TrimSpace(name)) {
return r, true
}
}
return Recipient{}, false
}
// ChannelTag is the Apprise tag for one recipient and channel, for example
// "harry_email".
func (c AppriseConfig) ChannelTag(r Recipient, channel string) string {
return r.TagBase + "_" + strings.ToLower(strings.TrimSpace(channel))
}
// IsParent reports whether the recipient receives the combined brief.
func (c AppriseConfig) IsParent(name string) bool {
for _, p := range c.Parents {
if strings.EqualFold(strings.TrimSpace(p), strings.TrimSpace(name)) {
return true
}
}
return false
}
// ParentRecipients returns the configured parents, in order.
func (c AppriseConfig) ParentRecipients() []Recipient {
out := make([]Recipient, 0, len(c.Parents))
for _, name := range c.Parents {
if r, ok := c.RecipientByName(name); ok {
out = append(out, r)
}
}
return out
}
// ValidChannels returns the requested channels that are configured, in order,
// and the ones that are not.
func (c AppriseConfig) ValidChannels() ([]string, []string) {
var ok, bad []string
for _, ch := range c.Channels {
switch ch {
case ChannelEmail, ChannelWhatsApp:
ok = append(ok, ch)
default:
bad = append(bad, ch)
}
}
return ok, bad
}
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
const DefaultTimezone = "Australia/Sydney"
@@ -137,6 +271,11 @@ func Load() (*Config, error) {
}
cfg.Location = loc
cfg.Calendar = loadCalendar()
apprise, err := loadApprise()
if err != nil {
return nil, err
}
cfg.Apprise = apprise
return cfg, nil
}
@@ -148,21 +287,95 @@ type DBConfig struct {
DBPath string
Location *time.Location
Calendar CalendarConfig
Apprise AppriseConfig
}
// LoadDB reads the database path and the family timezone only.
// LoadDB reads the database path, the family timezone, the calendar target and
// the notification gateway only.
func LoadDB() (*DBConfig, error) {
loc, err := time.LoadLocation(env("SCHOOL_TIMEZONE", DefaultTimezone))
if err != nil {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
}
apprise, err := loadApprise()
if err != nil {
return nil, err
}
return &DBConfig{
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
Location: loc,
Calendar: loadCalendar(),
Apprise: apprise,
}, nil
}
// loadApprise reads the Apprise gateway settings. Like the calendar target it
// never fails on an empty value: the service boots before the gateway is
// configured, and every send then fails loudly instead of silently.
func loadApprise() (AppriseConfig, error) {
c := AppriseConfig{
BaseURL: strings.TrimRight(env("SCHOOL_APPRISE_URL", DefaultAppriseURL), "/"),
Key: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_KEY")),
Token: strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TOKEN")),
Timeout: 15 * time.Second,
Channels: splitList(env("SCHOOL_APPRISE_CHANNELS", ChannelEmail+","+ChannelWhatsApp)),
Parents: splitList(env("SCHOOL_APPRISE_PARENTS", DefaultAppriseParents)),
NotifyToken: strings.TrimSpace(os.Getenv("SCHOOL_NOTIFY_TOKEN")),
}
if v := strings.TrimSpace(os.Getenv("SCHOOL_APPRISE_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
c.Timeout = d
}
}
recipients, err := parseRecipients(env("SCHOOL_APPRISE_TAGS", DefaultAppriseTags))
if err != nil {
return c, fmt.Errorf("SCHOOL_APPRISE_TAGS: %w", err)
}
c.Recipients = recipients
for _, p := range c.Parents {
if _, ok := c.RecipientByName(p); !ok {
return c, fmt.Errorf("SCHOOL_APPRISE_PARENTS: %q is not in SCHOOL_APPRISE_TAGS", p)
}
}
if _, bad := c.ValidChannels(); len(bad) > 0 {
return c, fmt.Errorf("SCHOOL_APPRISE_CHANNELS: unknown channel(s) %s", strings.Join(bad, ", "))
}
return c, nil
}
// parseRecipients reads the "Name=tagbase,Name=tagbase" recipient list. The tag
// base names the Apprise destinations, so a name that is wrong fails at send
// time with the Apprise error, not here.
func parseRecipients(v string) ([]Recipient, error) {
var out []Recipient
for _, entry := range splitList(v) {
name, tag, found := strings.Cut(entry, "=")
name = strings.TrimSpace(name)
tag = strings.TrimSpace(tag)
if !found || name == "" || tag == "" {
return nil, fmt.Errorf("expected Name=tag entries, got %q", entry)
}
out = append(out, Recipient{Name: name, TagBase: tag})
}
if len(out) == 0 {
return nil, fmt.Errorf("no recipients configured")
}
return out, nil
}
// splitList splits a comma list and drops empty entries.
func splitList(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
// loadCalendar reads the Home Assistant calendar target. It never fails: an
// empty value simply leaves the target disabled, so the service boots before
// the calendar is configured and the calendar commands fail loudly instead.