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.
222 lines
7.0 KiB
Go
222 lines
7.0 KiB
Go
// STOP keyword webhook — §5.2 of the notification-preferences paper.
|
|
//
|
|
// One endpoint handles Plivo + Twilio inbound webhooks because both
|
|
// providers post the same logical payload (from-number, body) just with
|
|
// different field names. We parse both shapes; the route doesn't care
|
|
// which provider hit it.
|
|
//
|
|
// On STOP / UNSUBSCRIBE / QUIT / CANCEL / END (case-insensitive):
|
|
//
|
|
// 1. Look up the user by primary_phone (E.164-normalised).
|
|
// 2. Set marketing_globally_muted = true and zero every marketing
|
|
// subscription.
|
|
// 3. Append a consent_log row (event=opt_out, channel=sms).
|
|
// 4. Return TwiML / Plivo XML acking the message; the worker tier's
|
|
// outbound layer sends the confirmation SMS via the existing
|
|
// provider chain (we never block on the outbound send here).
|
|
|
|
package routes
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
"github.com/hanzoai/dbx"
|
|
|
|
"github.com/hanzoai/notify/internal/preferences"
|
|
"github.com/hanzoai/notify/internal/schema"
|
|
)
|
|
|
|
// stopKeywords is the exact set the TCPA + carrier-best-practice docs
|
|
// say a marketing SMS must honour. We match the entire body trimmed of
|
|
// whitespace — partial-match would catch "stop talking to me" too which
|
|
// would over-trigger.
|
|
var stopKeywords = map[string]struct{}{
|
|
"STOP": {},
|
|
"UNSUBSCRIBE": {},
|
|
"QUIT": {},
|
|
"CANCEL": {},
|
|
"END": {},
|
|
}
|
|
|
|
// inboundSMSPayload is the parsed common shape. Plivo posts From + Text;
|
|
// Twilio posts From + Body — we read both keys so one handler covers
|
|
// both providers.
|
|
type inboundSMSPayload struct {
|
|
From string `json:"from"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// mountSMSInbound installs the Plivo + Twilio webhook. The handler
|
|
// accepts form-encoded (both providers' default) AND JSON (test
|
|
// harnesses) so the same route serves dev + prod.
|
|
func mountSMSInbound(r *router.Router[*core.RequestEvent], app core.App) {
|
|
r.POST("/v1/notify/sms-inbound", func(e *core.RequestEvent) error {
|
|
payload, err := parseInboundSMS(e)
|
|
if err != nil {
|
|
return apis.NewBadRequestError(err.Error(), nil)
|
|
}
|
|
// Normalise the keyword: providers preserve case + may include
|
|
// trailing punctuation. We strip both.
|
|
kw := strings.ToUpper(strings.TrimSpace(strings.Trim(payload.Body, ".!?")))
|
|
if _, hit := stopKeywords[kw]; !hit {
|
|
// Non-keyword inbound is logged-and-acked; we don't dump
|
|
// inbound text into prefs ever. Return 200 so the provider
|
|
// doesn't retry — there is nothing for notify to do.
|
|
return e.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
|
}
|
|
|
|
from := normalizeE164(payload.From)
|
|
if from == "" {
|
|
// Provider sent us garbage — ack so they don't retry, log a
|
|
// hint in the body for ops.
|
|
return e.JSON(http.StatusOK, map[string]string{
|
|
"status": "ignored",
|
|
"reason": "missing or non-E.164 from",
|
|
})
|
|
}
|
|
|
|
rec, err := findPreferencesByPhone(app, from)
|
|
if err != nil {
|
|
return apis.NewInternalServerError("lookup user by phone", err)
|
|
}
|
|
if rec == nil {
|
|
// No matching user — dead-letter by acking; ops looks at
|
|
// the inbound provider logs to find the orphan number.
|
|
return e.JSON(http.StatusOK, map[string]string{
|
|
"status": "ignored",
|
|
"reason": "no user for phone",
|
|
})
|
|
}
|
|
|
|
priorWire := preferences.ToWire(rec)
|
|
next := priorWire
|
|
next.MarketingGloballyMuted = true
|
|
next.MarketingSubscriptions = cloneSubs(priorWire.MarketingSubscriptions)
|
|
for k := range next.MarketingSubscriptions {
|
|
next.MarketingSubscriptions[k] = false
|
|
}
|
|
if err := preferences.ApplyWire(rec, &next); err != nil {
|
|
return apis.NewInternalServerError(err.Error(), nil)
|
|
}
|
|
if err := app.Save(rec); err != nil {
|
|
return apis.NewInternalServerError("save preferences", err)
|
|
}
|
|
if err := writeConsentLog(app, consentEntry{
|
|
UserID: rec.GetString("user_id"),
|
|
Org: rec.GetString("tenant"),
|
|
Event: schema.ConsentEventOptOut,
|
|
Category: "", // global
|
|
Channel: "sms",
|
|
PriorState: priorWire,
|
|
NewState: preferences.ToWire(rec),
|
|
IP: clientIP(e),
|
|
UserAgent: e.Request.Header.Get("User-Agent"),
|
|
}); err != nil {
|
|
return apis.NewInternalServerError("consent log", err)
|
|
}
|
|
|
|
// Return a confirmation reply via TwiML/Plivo XML. Both
|
|
// providers accept the same Twilio-style <Response><Message>…
|
|
// envelope when the response content type is text/xml; we
|
|
// emit that shape so a single response satisfies both.
|
|
body := `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Response><Message>You have unsubscribed from marketing SMS. Reply START to resubscribe.</Message></Response>`
|
|
e.Response.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
|
e.Response.WriteHeader(http.StatusOK)
|
|
_, err = e.Response.Write([]byte(body))
|
|
_ = time.Now // explicit import keep — see comment in preferences.go
|
|
return err
|
|
})
|
|
}
|
|
|
|
// parseInboundSMS handles both form-encoded webhooks (the default for
|
|
// Plivo + Twilio) and JSON (test harnesses + future webhook variants).
|
|
// We coerce to lowercase keys so case differences across providers
|
|
// don't matter.
|
|
func parseInboundSMS(e *core.RequestEvent) (inboundSMSPayload, error) {
|
|
out := inboundSMSPayload{}
|
|
|
|
contentType := e.Request.Header.Get("Content-Type")
|
|
if strings.HasPrefix(contentType, "application/json") {
|
|
if err := e.BindBody(&out); err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Form-encoded. Parse + scan for the canonical keys. We accept any
|
|
// case so both Plivo ("From", "Text") and Twilio ("From", "Body")
|
|
// land in the same payload. The lower-case fallback covers
|
|
// hand-rolled clients and curl probes.
|
|
if err := e.Request.ParseForm(); err != nil {
|
|
return out, err
|
|
}
|
|
for _, k := range []string{"From", "from"} {
|
|
if v := e.Request.PostFormValue(k); v != "" {
|
|
out.From = v
|
|
break
|
|
}
|
|
}
|
|
for _, k := range []string{"Body", "body", "Text", "text"} {
|
|
if v := e.Request.PostFormValue(k); v != "" {
|
|
out.Body = v
|
|
break
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// findPreferencesByPhone locates the prefs row for the (org, phone)
|
|
// pair. The schema has a partial index on (primary_phone) so this is a
|
|
// single-row read; the rows are uniquified across orgs by inbound
|
|
// number (the same phone can only belong to one org).
|
|
func findPreferencesByPhone(app core.App, phone string) (*core.Record, error) {
|
|
rows, err := app.FindRecordsByFilter(
|
|
schema.Preferences,
|
|
"primary_phone = {:phone}",
|
|
"-created", 1, 0,
|
|
dbx.Params{"phone": phone},
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
return rows[0], nil
|
|
}
|
|
|
|
// normalizeE164 strips spaces/dashes/parens and prepends '+' when
|
|
// absent. Anything that doesn't look E.164 after normalisation returns
|
|
// empty.
|
|
func normalizeE164(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
clean := strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= '0' && r <= '9':
|
|
return r
|
|
case r == '+':
|
|
return r
|
|
}
|
|
return -1
|
|
}, s)
|
|
if clean == "" {
|
|
return ""
|
|
}
|
|
if clean[0] != '+' {
|
|
clean = "+" + clean
|
|
}
|
|
if len(clean) < 8 || len(clean) > 16 {
|
|
return ""
|
|
}
|
|
return clean
|
|
}
|