Files
notify/internal/marketing/listunsubscribe_test.go
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

88 lines
2.6 KiB
Go

package marketing
import (
"strings"
"testing"
"time"
"github.com/hanzoai/notify/internal/unsubscribe"
)
func TestBuildHeaders(t *testing.T) {
t.Parallel()
signer, err := unsubscribe.NewSigner("test-secret")
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
cfg := Config{Env: "dev", Signer: signer}
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
h, err := cfg.Build("u1", "promotional", now, unsubscribe.Default30Days)
if err != nil {
t.Fatalf("Build: %v", err)
}
if !strings.HasPrefix(h.URL, "https://notify.dev./v1/notify/unsubscribe/") {
t.Fatalf("unexpected URL host: %s", h.URL)
}
if !strings.Contains(h.ListUnsubscribe, "<"+h.URL+">") {
t.Fatalf("List-Unsubscribe missing HTTPS URL: %s", h.ListUnsubscribe)
}
if !strings.Contains(h.ListUnsubscribe, "mailto:unsubscribe+") {
t.Fatalf("List-Unsubscribe missing mailto: %s", h.ListUnsubscribe)
}
if h.ListUnsubscribePost != "List-Unsubscribe=One-Click" {
t.Fatalf("unexpected List-Unsubscribe-Post: %s", h.ListUnsubscribePost)
}
}
func TestBuildHeadersProdHost(t *testing.T) {
t.Parallel()
signer, _ := unsubscribe.NewSigner("k")
for _, env := range []string{"", "main", "prod", "production"} {
cfg := Config{Env: env, Signer: signer}
h, err := cfg.Build("u1", "", time.Now().UTC(), unsubscribe.Default30Days)
if err != nil {
t.Fatalf("Build(%q): %v", env, err)
}
if !strings.HasPrefix(h.URL, "https://notify./") {
t.Fatalf("env=%q: want notify. host, got %s", env, h.URL)
}
}
}
func TestBuildHeadersTokenRoundTrip(t *testing.T) {
t.Parallel()
signer, _ := unsubscribe.NewSigner("k")
cfg := Config{Env: "test", Signer: signer}
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
h, err := cfg.Build("user-42", "newsletter", now, time.Hour)
if err != nil {
t.Fatalf("Build: %v", err)
}
// Pull the token out of the URL and verify it.
tok := strings.TrimPrefix(h.URL, "https://notify.test./v1/notify/unsubscribe/")
p, err := signer.Verify(tok, now)
if err != nil {
t.Fatalf("Verify minted token: %v", err)
}
if p.UserID != "user-42" || p.Category != "newsletter" {
t.Fatalf("unexpected payload: %+v", p)
}
}
func TestBuildHeadersNilSigner(t *testing.T) {
t.Parallel()
cfg := Config{}
if _, err := cfg.Build("u1", "", time.Now().UTC(), time.Hour); err == nil {
t.Fatalf("nil signer should yield error")
}
}
func TestFooterLine(t *testing.T) {
t.Parallel()
h := Headers{URL: "https://notify./v1/notify/unsubscribe/abc"}
want := "Unsubscribe: https://notify./v1/notify/unsubscribe/abc"
if h.FooterLine() != want {
t.Fatalf("FooterLine = %q, want %q", h.FooterLine(), want)
}
}