Files
zeekayandGitHub 8466cf5a79 notify: phase 1 — preferences, channel router, one-click unsubscribe, STOP keyword (#7)
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.
2026-06-03 20:08:33 -07:00

163 lines
5.4 KiB
Go

// Package unsubscribe is the HMAC-signed token primitive that backs
// the one-click email unsubscribe endpoints from §5.1 of the
// notification-preferences paper.
//
// Token shape (base64url, no padding):
//
// body = user_id || "|" || category || "|" || expires_at_unix
// mac = HMAC-SHA256(NOTIFY_UNSUBSCRIBE_SECRET, body)
// tok = base64url(mac || body)
//
// The first 32 bytes of the decoded token are the MAC; the rest is the
// plaintext payload. Verification recomputes the MAC over the payload
// and constant-time-compares against the prefix. A consumed-state
// check is layered on top via the notification_unsubscribe_tokens
// collection — Sign+Verify are pure (no DB).
//
// Why not JWT: every byte counts in a URL that has to fit in mail
// clients' wrap rules and in the SMS-like List-Unsubscribe-Post space.
// HMAC + a 3-field tuple is the smallest thing that works.
package unsubscribe
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
// macLen is the SHA-256 digest size in bytes. The token's first macLen
// bytes are the MAC; everything after is the payload.
const macLen = sha256.Size
// fieldSep separates the three payload fields. Pipe is illegal in
// user_id (kebab/uuid) and category (snake_case) so it cleanly delimits.
const fieldSep = "|"
// Default30Days is the standard expiry window — CAN-SPAM requires
// processing within 10 business days, GDPR within 30; the paper §5.1
// pins 30 days.
const Default30Days = 30 * 24 * time.Hour
// Payload is the verified inner state. Empty Category means "unsubscribe
// from all marketing"; the paper §5.1 + the consent-log schema use ""
// for the global-unsub case.
type Payload struct {
UserID string
Category string
ExpiresAt time.Time
}
// Signer constructs and verifies tokens with a secret key. The key
// comes from NOTIFY_UNSUBSCRIBE_SECRET — fail-closed boot if missing in
// production mode.
type Signer struct {
secret []byte
}
// NewSigner returns a Signer for the given secret. Empty secret is
// rejected — every caller should resolve this from env at boot.
func NewSigner(secret string) (*Signer, error) {
if strings.TrimSpace(secret) == "" {
return nil, errors.New("unsubscribe: NOTIFY_UNSUBSCRIBE_SECRET is required")
}
return &Signer{secret: []byte(secret)}, nil
}
// Sign returns a base64url token for (userID, category, expiresAt).
// expiresAt is truncated to second precision because the token carries
// a unix timestamp.
func (s *Signer) Sign(userID, category string, expiresAt time.Time) (string, error) {
if s == nil {
return "", errors.New("unsubscribe: nil signer")
}
if strings.TrimSpace(userID) == "" {
return "", errors.New("unsubscribe: user_id is required")
}
if strings.ContainsAny(userID, fieldSep) {
return "", fmt.Errorf("unsubscribe: user_id may not contain %q", fieldSep)
}
if strings.ContainsAny(category, fieldSep) {
return "", fmt.Errorf("unsubscribe: category may not contain %q", fieldSep)
}
if expiresAt.IsZero() {
return "", errors.New("unsubscribe: expiresAt is required")
}
payload := userID + fieldSep + category + fieldSep + strconv.FormatInt(expiresAt.Unix(), 10)
h := hmac.New(sha256.New, s.secret)
h.Write([]byte(payload))
mac := h.Sum(nil)
buf := make([]byte, 0, len(mac)+len(payload))
buf = append(buf, mac...)
buf = append(buf, []byte(payload)...)
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// SignWithTTL is a convenience that signs a token expiring `ttl` from
// now. now is injectable for tests.
func (s *Signer) SignWithTTL(userID, category string, now time.Time, ttl time.Duration) (string, error) {
return s.Sign(userID, category, now.Add(ttl))
}
// Verify decodes + authenticates a token. Returns the verified Payload
// or an error explaining the failure mode.
//
// Errors are intentionally non-leaking — they say "invalid" or
// "expired" without dumping the parsed payload, so a brute-force
// attacker learns no internal state.
func (s *Signer) Verify(token string, now time.Time) (Payload, error) {
if s == nil {
return Payload{}, ErrInvalid
}
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token))
if err != nil {
return Payload{}, ErrInvalid
}
if len(raw) <= macLen {
return Payload{}, ErrInvalid
}
mac := raw[:macLen]
body := raw[macLen:]
h := hmac.New(sha256.New, s.secret)
h.Write(body)
expected := h.Sum(nil)
if !hmac.Equal(mac, expected) {
return Payload{}, ErrInvalid
}
// Split the body. We require EXACTLY three fields. Splitting on
// the LAST `fieldSep` once for the timestamp would silently accept
// pipes in user_id; instead we hard-bound the field count.
parts := strings.Split(string(body), fieldSep)
if len(parts) != 3 {
return Payload{}, ErrInvalid
}
exp, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
return Payload{}, ErrInvalid
}
expiresAt := time.Unix(exp, 0).UTC()
if !expiresAt.After(now) {
return Payload{}, ErrExpired
}
return Payload{
UserID: parts[0],
Category: parts[1],
ExpiresAt: expiresAt,
}, nil
}
// ErrInvalid is returned for any failed decode / HMAC mismatch / bad
// payload shape. Generic by design.
var ErrInvalid = errors.New("unsubscribe: invalid token")
// ErrExpired is returned for tokens whose ExpiresAt is in the past.
// Distinct from ErrInvalid so the route can render a "this link
// expired" page rather than a generic error.
var ErrExpired = errors.New("unsubscribe: token expired")