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:
127
internal/config/apprise_test.go
Normal file
127
internal/config/apprise_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user