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.
88 lines
2.6 KiB
Go
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)
|
|
}
|
|
}
|