mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
Substrate for the notification-preferences-and-channel-routing paper.
WhatsApp + push are Phase 2/3 — Channel constants for `wa` and `push`
are declared so the wire shape is forward-compatible, but the router
never returns them in Phase 1.
Substrates landed in this PR:
1. Data model — 3 new collections wired through schema/schema.go:
notification_preferences (user-scoped channel + marketing prefs),
notification_consent_log (append-only audit, FINRA 5y retention),
notification_unsubscribe_tokens (consumable HMAC tokens, PK = token).
2. Channel router — internal/router/ is a pure (prefs, category, now)
→ []channel function: no IO, no DB reads. Table-driven test walks
every cell in §3 of the paper (11 categories × 5 channels = 55
cells); the matrix copy in the test is the canonical guard against
future drift.
3. CRUD API — routes/preferences.go: GET/PUT /v1/notify/preferences
(user reads + writes own; auto-creates default row on first GET;
PUT writes prior+new state to consent log), GET /audit, plus
admin freeze/unfreeze and audit under /v1/notify/admin/... gated by
NOTIFY_ADMIN_KEY Bearer.
4. One-click email unsubscribe — internal/unsubscribe/ HMAC-SHA256
token (user_id|category|expires_at_unix) base64url-encoded;
routes/unsubscribe.go renders a no-JS confirmation page, flips
the bit on POST, mirrors with /resubscribe/{token}. Token is the
auth — the HMAC means only the email recipient can reach the
endpoint. Default 30-day TTL.
5. List-Unsubscribe header builder — internal/marketing/ produces
the RFC 8058 + RFC 2369 header pair callers inject into outbound
marketing emails. Decoupled from the existing send path so the
SES SendEmail → SendRawEmail switch can land independently.
6. STOP keyword webhook — routes/sms_inbound.go single endpoint
accepts both Plivo and Twilio (same logical payload; we read
From/Text and From/Body), matches STOP|UNSUBSCRIBE|QUIT|CANCEL|END
case-insensitive on the trimmed body, resolves by primary_phone
(E.164-normalised: strip whitespace/dashes/parens, prepend '+'),
sets marketing_globally_muted=true, zeros every marketing
subscription, writes opt_out consent_log row, replies with a
Twilio/Plivo-shaped XML confirmation envelope.
Defaults: every new user starts with every marketing category OFF;
transactional categories always deliver via email per regulation;
quiet hours 21:00–08:00 America/New_York (TCPA-compliant baseline).
Boot fail-closed: NOTIFY_UNSUBSCRIBE_SECRET is required in
production; empty crashes notifyd. NOTIFY_TEST_MODE=1 degrades the
unsubscribe routes to 503 while the rest of the surface keeps
working.
The existing /v1/notify/send surface and ProviderChain/Resolver/
PlivoResolver are not modified — every new piece composes on top.
LLM.md updated with the Phase 1 invariants + new route table rows.
288 lines
9.4 KiB
Go
288 lines
9.4 KiB
Go
// Package preferences holds the wire shape + validation + row-mapper
|
|
// for the notification_preferences collection.
|
|
//
|
|
// This package is intentionally separate from internal/router/ because
|
|
// the router is a pure function operating on a resolved Preferences
|
|
// struct, while this package owns the persistence + JSON marshalling
|
|
// boilerplate. Keeping them apart preserves the router's
|
|
// no-dependencies-but-time discipline.
|
|
package preferences
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/mail"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
|
|
"github.com/hanzoai/notify/internal/router"
|
|
)
|
|
|
|
// Wire is the JSON shape returned by GET /v1/notify/preferences and
|
|
// accepted by PUT /v1/notify/preferences. Field order + names match
|
|
// §2.1 of the notification-preferences paper.
|
|
type Wire struct {
|
|
ID string `json:"id,omitempty"`
|
|
UserID string `json:"user_id"`
|
|
OrgID string `json:"org_id"`
|
|
|
|
PrimaryEmail string `json:"primary_email"`
|
|
BackupEmail string `json:"backup_email,omitempty"`
|
|
PrimaryPhone string `json:"primary_phone,omitempty"`
|
|
WhatsAppPhone string `json:"whatsapp_phone,omitempty"`
|
|
LegalEmail string `json:"legal_email"`
|
|
|
|
PreferredChannels []string `json:"preferred_channels"`
|
|
RealtimeChannels []string `json:"realtime_channels"`
|
|
MarketingSubscriptions map[string]bool `json:"marketing_subscriptions"`
|
|
|
|
QuietHoursStart string `json:"quiet_hours_start"`
|
|
QuietHoursEnd string `json:"quiet_hours_end"`
|
|
Timezone string `json:"timezone"`
|
|
|
|
MarketingGloballyMuted bool `json:"marketing_globally_muted"`
|
|
|
|
Created string `json:"created,omitempty"`
|
|
Updated string `json:"updated,omitempty"`
|
|
}
|
|
|
|
// Validate enforces the structural rules from §2: required emails,
|
|
// HH:MM format, known channel ids, known marketing category ids. Bad
|
|
// input is rejected at the API boundary — the persistence layer trusts
|
|
// what it stores.
|
|
func Validate(w *Wire) error {
|
|
if w == nil {
|
|
return errors.New("preferences: nil body")
|
|
}
|
|
if strings.TrimSpace(w.PrimaryEmail) == "" {
|
|
return errors.New("primary_email is required")
|
|
}
|
|
if _, err := mail.ParseAddress(w.PrimaryEmail); err != nil {
|
|
return fmt.Errorf("primary_email invalid: %w", err)
|
|
}
|
|
if w.BackupEmail != "" {
|
|
if _, err := mail.ParseAddress(w.BackupEmail); err != nil {
|
|
return fmt.Errorf("backup_email invalid: %w", err)
|
|
}
|
|
}
|
|
if strings.TrimSpace(w.LegalEmail) == "" {
|
|
return errors.New("legal_email is required")
|
|
}
|
|
if _, err := mail.ParseAddress(w.LegalEmail); err != nil {
|
|
return fmt.Errorf("legal_email invalid: %w", err)
|
|
}
|
|
if w.PrimaryPhone != "" && !looksLikeE164(w.PrimaryPhone) {
|
|
return errors.New("primary_phone must be E.164 (e.g. +15555550100)")
|
|
}
|
|
if w.WhatsAppPhone != "" && !looksLikeE164(w.WhatsAppPhone) {
|
|
return errors.New("whatsapp_phone must be E.164")
|
|
}
|
|
if w.QuietHoursStart != "" && !looksLikeHM(w.QuietHoursStart) {
|
|
return errors.New("quiet_hours_start must be HH:MM")
|
|
}
|
|
if w.QuietHoursEnd != "" && !looksLikeHM(w.QuietHoursEnd) {
|
|
return errors.New("quiet_hours_end must be HH:MM")
|
|
}
|
|
if strings.TrimSpace(w.Timezone) == "" {
|
|
return errors.New("timezone is required")
|
|
}
|
|
for _, ch := range w.PreferredChannels {
|
|
if !knownChannel(ch) {
|
|
return fmt.Errorf("unknown channel in preferred_channels: %q", ch)
|
|
}
|
|
}
|
|
for _, ch := range w.RealtimeChannels {
|
|
if !knownChannel(ch) {
|
|
return fmt.Errorf("unknown channel in realtime_channels: %q", ch)
|
|
}
|
|
}
|
|
for cat := range w.MarketingSubscriptions {
|
|
if !knownMarketingCategory(cat) {
|
|
return fmt.Errorf("unknown marketing category: %q", cat)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyWire writes the Wire fields into the *core.Record. user_id /
|
|
// tenant are forced by the caller so this only writes the mutable
|
|
// columns.
|
|
func ApplyWire(rec *core.Record, w *Wire) error {
|
|
if rec == nil || w == nil {
|
|
return errors.New("preferences: nil record or wire")
|
|
}
|
|
rec.Set("user_id", w.UserID)
|
|
rec.Set("tenant", w.OrgID)
|
|
rec.Set("primary_email", w.PrimaryEmail)
|
|
rec.Set("backup_email", w.BackupEmail)
|
|
rec.Set("primary_phone", w.PrimaryPhone)
|
|
rec.Set("whatsapp_phone", w.WhatsAppPhone)
|
|
rec.Set("legal_email", w.LegalEmail)
|
|
rec.Set("preferred_channels", nonNilStrings(w.PreferredChannels))
|
|
rec.Set("realtime_channels", nonNilStrings(w.RealtimeChannels))
|
|
rec.Set("marketing_subscriptions", nonNilMarketing(w.MarketingSubscriptions))
|
|
rec.Set("quiet_hours_start", w.QuietHoursStart)
|
|
rec.Set("quiet_hours_end", w.QuietHoursEnd)
|
|
rec.Set("timezone", w.Timezone)
|
|
rec.Set("marketing_globally_muted", w.MarketingGloballyMuted)
|
|
return nil
|
|
}
|
|
|
|
// ToWire materialises a Wire from a *core.Record. JSON blobs are
|
|
// parsed defensively — a corrupt blob falls back to an empty slice/map
|
|
// rather than failing the read.
|
|
func ToWire(rec *core.Record) Wire {
|
|
if rec == nil {
|
|
return Wire{}
|
|
}
|
|
return Wire{
|
|
ID: rec.Id,
|
|
UserID: rec.GetString("user_id"),
|
|
OrgID: rec.GetString("tenant"),
|
|
PrimaryEmail: rec.GetString("primary_email"),
|
|
BackupEmail: rec.GetString("backup_email"),
|
|
PrimaryPhone: rec.GetString("primary_phone"),
|
|
WhatsAppPhone: rec.GetString("whatsapp_phone"),
|
|
LegalEmail: rec.GetString("legal_email"),
|
|
PreferredChannels: parseStringSlice(rec.GetString("preferred_channels")),
|
|
RealtimeChannels: parseStringSlice(rec.GetString("realtime_channels")),
|
|
MarketingSubscriptions: parseSubscriptions(rec.GetString("marketing_subscriptions")),
|
|
QuietHoursStart: rec.GetString("quiet_hours_start"),
|
|
QuietHoursEnd: rec.GetString("quiet_hours_end"),
|
|
Timezone: rec.GetString("timezone"),
|
|
MarketingGloballyMuted: rec.GetBool("marketing_globally_muted"),
|
|
Created: rec.GetString("created"),
|
|
Updated: rec.GetString("updated"),
|
|
}
|
|
}
|
|
|
|
// ToRouterPreferences converts a Wire into the router's Preferences
|
|
// struct. Caller of router.DeliveryChannels uses this to bridge the
|
|
// persistence and routing layers.
|
|
func ToRouterPreferences(w Wire) *router.Preferences {
|
|
pc := make([]router.Channel, 0, len(w.PreferredChannels))
|
|
for _, c := range w.PreferredChannels {
|
|
pc = append(pc, router.Channel(c))
|
|
}
|
|
rc := make([]router.Channel, 0, len(w.RealtimeChannels))
|
|
for _, c := range w.RealtimeChannels {
|
|
rc = append(rc, router.Channel(c))
|
|
}
|
|
ms := make(map[router.Category]bool, len(w.MarketingSubscriptions))
|
|
for k, v := range w.MarketingSubscriptions {
|
|
ms[router.Category(k)] = v
|
|
}
|
|
return &router.Preferences{
|
|
UserID: w.UserID,
|
|
OrgID: w.OrgID,
|
|
PrimaryEmail: w.PrimaryEmail,
|
|
BackupEmail: w.BackupEmail,
|
|
PrimaryPhone: w.PrimaryPhone,
|
|
WhatsAppPhone: w.WhatsAppPhone,
|
|
LegalEmail: w.LegalEmail,
|
|
PreferredChannels: pc,
|
|
RealtimeChannels: rc,
|
|
MarketingSubscriptions: ms,
|
|
QuietHoursStart: w.QuietHoursStart,
|
|
QuietHoursEnd: w.QuietHoursEnd,
|
|
Timezone: w.Timezone,
|
|
MarketingGloballyMuted: w.MarketingGloballyMuted,
|
|
}
|
|
}
|
|
|
|
// knownChannel filters PUT input to the set Phase 1+ understands.
|
|
// wa+push are accepted on the wire (so the API survives the Phase 2
|
|
// flip) but the router gates them off.
|
|
func knownChannel(c string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(c)) {
|
|
case "email", "sms", "wa", "web", "push":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// knownMarketingCategory enumerates the §3.2 marketing set. Transactional
|
|
// categories MUST NOT appear in marketing_subscriptions; the matrix
|
|
// already forces them on by regulation.
|
|
func knownMarketingCategory(c string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(c)) {
|
|
case "promotional", "newsletter", "product_update", "partner_offer", "research":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// looksLikeHM checks "HH:MM". The router parser is the actual truth
|
|
// source for quiet-hours math; this is a fast input gate.
|
|
func looksLikeHM(s string) bool {
|
|
parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
h, m := parts[0], parts[1]
|
|
if len(h) != 2 || len(m) != 2 {
|
|
return false
|
|
}
|
|
for _, c := range h + m {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// e164 matches the canonical international phone number shape.
|
|
var e164 = regexp.MustCompile(`^\+[1-9][0-9]{6,14}$`)
|
|
|
|
func looksLikeE164(s string) bool {
|
|
return e164.MatchString(strings.TrimSpace(s))
|
|
}
|
|
|
|
// nonNilStrings collapses nil to empty slice so the JSON encoder emits
|
|
// `[]` rather than `null` — matches the schema's default.
|
|
func nonNilStrings(xs []string) []string {
|
|
if xs == nil {
|
|
return []string{}
|
|
}
|
|
return xs
|
|
}
|
|
|
|
// nonNilMarketing mirrors nonNilStrings for the marketing map.
|
|
func nonNilMarketing(m map[string]bool) map[string]bool {
|
|
if m == nil {
|
|
return map[string]bool{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// parseStringSlice decodes the JSON blob stored in preferred_channels /
|
|
// realtime_channels. Empty / corrupt → empty slice.
|
|
func parseStringSlice(raw string) []string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return []string{}
|
|
}
|
|
var xs []string
|
|
if err := json.Unmarshal([]byte(raw), &xs); err != nil {
|
|
return []string{}
|
|
}
|
|
return xs
|
|
}
|
|
|
|
// parseSubscriptions decodes the JSON map stored in
|
|
// marketing_subscriptions. Empty / corrupt → empty map.
|
|
func parseSubscriptions(raw string) map[string]bool {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return map[string]bool{}
|
|
}
|
|
var m map[string]bool
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
return map[string]bool{}
|
|
}
|
|
return m
|
|
}
|