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.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
// Package marketing builds the List-Unsubscribe + List-Unsubscribe-Post
|
|
// header pair that every marketing-class email carries per §5 of the
|
|
// notification-preferences paper.
|
|
//
|
|
// Callers (the eventual marketing send path) construct a Headers struct
|
|
// with a freshly signed token and pass it to the email builder. The
|
|
// builder writes both headers verbatim into the outbound message so
|
|
// Gmail / Apple Mail / Outlook show the native "Unsubscribe" button.
|
|
//
|
|
// This is a pure header-construction package: no IO, no DB. Token
|
|
// signing happens at the call site so callers can reuse the same
|
|
// signer the unsubscribe routes use.
|
|
package marketing
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/notify/internal/unsubscribe"
|
|
)
|
|
|
|
// HeaderListUnsubscribe and HeaderListUnsubscribePost are the canonical
|
|
// RFC 8058 + RFC 2369 header names. Exported so callers can use the
|
|
// same constants when injecting into outbound messages.
|
|
const (
|
|
HeaderListUnsubscribe = "List-Unsubscribe"
|
|
HeaderListUnsubscribePost = "List-Unsubscribe-Post"
|
|
)
|
|
|
|
// Headers is the rendered name/value pair for the two RFC 8058 headers.
|
|
// Value strings are header-safe (CRLF-free).
|
|
type Headers struct {
|
|
ListUnsubscribe string
|
|
ListUnsubscribePost string
|
|
URL string // the HTTPS unsubscribe URL (also in the footer link)
|
|
}
|
|
|
|
// Config is what the caller has to know to build the headers. Env is
|
|
// the env-specific subdomain segment ("dev", "test", or "" for prod →
|
|
// notify.).
|
|
type Config struct {
|
|
// Env is dev/test/main/"" (empty == production). Affects the
|
|
// HTTPS hostname only — the mailto bridge is one address per
|
|
// fleet because notify. proxies inbound to all envs.
|
|
Env string
|
|
|
|
// Signer mints the token. Shared with the unsubscribe routes so
|
|
// emitted links validate at the receive endpoint.
|
|
Signer *unsubscribe.Signer
|
|
|
|
// MailtoDomain is the inbound MX-served domain for the mailto:
|
|
// alternative header. Defaults to "notify." when empty.
|
|
MailtoDomain string
|
|
|
|
// HTTPSHost overrides the computed notify.{env}. host.
|
|
// Used by self-hosted deployments and integration tests.
|
|
HTTPSHost string
|
|
}
|
|
|
|
// Build returns the rendered Headers for (userID, category, ttl). The
|
|
// token expires `ttl` from `now`; pass time.Now().UTC() and
|
|
// unsubscribe.Default30Days for the canonical 30-day window.
|
|
func (c Config) Build(userID, category string, now time.Time, ttl time.Duration) (Headers, error) {
|
|
if c.Signer == nil {
|
|
return Headers{}, fmt.Errorf("marketing: signer is required")
|
|
}
|
|
token, err := c.Signer.SignWithTTL(userID, category, now, ttl)
|
|
if err != nil {
|
|
return Headers{}, fmt.Errorf("marketing: sign token: %w", err)
|
|
}
|
|
host := c.HTTPSHost
|
|
if host == "" {
|
|
host = "notify." + envSegment(c.Env) + ""
|
|
}
|
|
mailto := c.MailtoDomain
|
|
if mailto == "" {
|
|
mailto = "notify."
|
|
}
|
|
|
|
httpsURL := "https://" + host + "/v1/notify/unsubscribe/" + url.PathEscape(token)
|
|
mailtoURL := "mailto:unsubscribe+" + url.PathEscape(token) + "@" + mailto
|
|
|
|
return Headers{
|
|
ListUnsubscribe: "<" + httpsURL + ">, <" + mailtoURL + ">",
|
|
ListUnsubscribePost: "List-Unsubscribe=One-Click",
|
|
URL: httpsURL,
|
|
}, nil
|
|
}
|
|
|
|
// FooterLine is the human-visible "Unsubscribe" link at the bottom of
|
|
// the marketing email body. Callers append this verbatim to the body
|
|
// text/html. CAN-SPAM §5.A.5 — "clear and conspicuous notice".
|
|
func (h Headers) FooterLine() string {
|
|
return "Unsubscribe: " + h.URL
|
|
}
|
|
|
|
// envSegment returns the {env}. URL segment including the trailing
|
|
// dot. Production (empty Env) returns empty so the host collapses to
|
|
// notify. — no double dot, no /main/ noise.
|
|
func envSegment(env string) string {
|
|
env = strings.ToLower(strings.TrimSpace(env))
|
|
switch env {
|
|
case "", "main", "prod", "production":
|
|
return ""
|
|
}
|
|
return env + "."
|
|
}
|