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

@@ -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.