- DefaultBrand const flipped from 'liquidity' to 'hanzo' (plivo_resolver.go, chain.go and all doc comments) - Replace <vendor> unsubscribe / mailto hostnames with hanzo.ai in listunsubscribe.go and all marketing tests (raw_test, category_test, marketing_test, etc.) - Rename seed<tenant> test fixture → seedDefault and switch its tenant literals from 'liquidity' to use DefaultBrand - Replace 'liquidity' tenant literals in chain_test / chain_marketing_test / preferences_test / router_test / kmsbridge client_test with 'acme' (generic test tenant) — tests still cover the brand→default fallback semantics - Drop github.com/<tenant>/* from GOPRIVATE in Dockerfile - Drop <tenant>/operator + '<tenant>/bd#105' references from k8s/kustomization.yaml + pkg/types/types.go comments - Replace 'LiquidReplicate sidecar' with generic 'replicator sidecar' in schema.go consent-log retention comment - Rewrite LLM.md fleet-default KMS path examples to use brand/hanzo/* Tests pass: tenant, marketing, preferences, kmsbridge, router.
14 KiB
hanzoai/notify
Canonical Hanzo messaging library + standalone service.
The library lives at the module root (github.com/hanzoai/notify) and
provides 33 provider impls under service/{plivo, twilio, sendgrid, mail, …}. The service that wraps the library lives in cmd/notifyd
and follows the hanzoai/auto layout exactly — same hanzoai/base +
hanzoai/tasks stack, same Docker shape, same K8s envelope.
Repo layout
. library — Notify / Notifier / providers
├── service/{plivo,…} 33 provider implementations (untouched)
├── cmd/notifyd/ service binary (main)
├── internal/
│ ├── boot/ KMS facade wiring (via platform plugin)
│ ├── schema/ 9 base collections (tenants, providers, templates, events, messages, meter, notification_preferences, notification_consent_log, notification_unsubscribe_tokens)
│ ├── routes/ /v1/notify/* HTTP handlers
│ ├── router/ Phase 1: pure (prefs, category, time) → []channel function
│ ├── preferences/ Phase 1: wire/validate/row-mapper for notification_preferences
│ ├── unsubscribe/ Phase 1: HMAC token sign/verify (one-click email unsubscribe)
│ ├── marketing/ Phase 1: List-Unsubscribe + List-Unsubscribe-Post header builder
│ ├── tenant/ per-tenant Notifier resolver (KMS-backed creds)
│ ├── template/ lifecycle + Go text/template rendering
│ ├── event/ event catalog routing
│ ├── metering/ per-send ledger + aggregation
│ ├── tasks/ hanzoai/tasks workflow + activity for async sends
│ └── zaprpc/ procedure constants (server impl land later)
└── pkg/ public consumer SDK
├── client/ HTTP client → notifyd
└── types/ wire shapes shared by routes + client
Surface (HTTP, all under /v1/notify/)
| Method | Path | Owner |
|---|---|---|
| GET | /health |
routes/health.go |
| POST | /send (+ /send/{channel}) |
routes/send.go |
| GET | /messages + /messages/{id} |
routes/messages.go |
| GET | /providers |
routes/providers.go |
| POST | /providers + /{id}/{test,activate,disable} |
routes/providers.go |
| GET | /templates |
routes/templates.go |
| POST | /templates + /{id}/{submit,approve,publish,archive} |
routes/templates.go |
| GET | /events, POST /events |
routes/events.go |
| GET | /metering?from=&to= |
routes/metering.go |
| GET | /brand/plivo |
routes/brand_plivo.go (metadata only — no secrets) |
| PUT | /brand/plivo |
routes/brand_plivo.go (writes KMS) |
| DELETE | /brand/plivo |
routes/brand_plivo.go (clears KMS — falls back to default) |
| POST | /brand/plivo/test |
routes/brand_plivo.go (probe SMS via resolved creds) |
| GET | /preferences |
routes/preferences.go (user reads own; auto-create defaults) |
| PUT | /preferences |
routes/preferences.go (user updates own; appends consent log) |
| GET | /preferences/audit |
routes/preferences.go (user's own consent log) |
| GET | /admin/preferences/{user_id}/audit |
routes/preferences.go (admin: read trail; Bearer NOTIFY_ADMIN_KEY) |
| POST | /admin/preferences/{user_id}/freeze |
routes/preferences.go (admin: marketing_globally_muted=true) |
| POST | /admin/preferences/{user_id}/unfreeze |
routes/preferences.go (admin: marketing_globally_muted=false) |
| GET | /unsubscribe/{token} |
routes/unsubscribe.go (no-JS confirmation page) |
| POST | /unsubscribe/{token} |
routes/unsubscribe.go (opt-out; token IS auth via HMAC) |
| POST | /resubscribe/{token} |
routes/unsubscribe.go (opt-in mirror) |
| POST | /sms-inbound |
routes/sms_inbound.go (Plivo + Twilio STOP keyword webhook) |
Tenant scope: every route reads X-Org-Id from the platform plugin
(populated from JWT owner claim). Cross-tenant access is impossible
at the SQL filter level.
Provider credentials — KMS layout
shared/{service}/{key} # Hanzo subaccount (legacy DB-row path)
tenants/{slug}/{service}/{key} # BYO per tenant (DB-row path)
brand/{slug}/{provider}/{key-kebab} # per-brand chain provider creds
brand/hanzo/{provider}/{key-kebab} # fleet default (fail-closed if missing)
brand/{slug}/notify-chain/{channel} # JSON array of provider ids — per-channel order override
Three resolution paths:
-
DB-row resolver (
internal/tenant/tenant.go) — looks up a row in theproviderscollection scoped to X-Org-Id, readskms_path, appends the field name, fetches each value from KMS. Used by the legacy single-provider?provider=...pinning path. -
Brand resolver (
internal/tenant/plivo_resolver.go) — used ONLY for the/v1/notify/brand/plivo*endpoints (platform UI's "current effective Plivo" indicator). Readsbrand/<slug>/plivo/*directly; on miss falls back tobrand/hanzo/plivo/*. Fail-closed if the default brand record is missing — no hard-coded fallback. -
Chain resolver (
internal/tenant/chain.go) — default send path. Builds a per-(brand, channel) ordered provider chain (primary → fallback1 → fallback2). The order comes from KMS atbrand/<slug>/notify-chain/<channel>(JSON array of provider ids); absent →DefaultChainFor(channel):sms → plivo, twilio email_txn → ses_api, ses_smtp email_otp → ses_smtp, ses_api email_marketing → sendgrid, ses_apiEach provider's credentials are read from
brand/<slug>/<provider>/<key-kebab>with brand→default-brand fallback on missing fields. Per-attempt deadline 10s, whole-chain ceiling 30s. Errors are classified terminal (4xx, invalid recipient, blocklist) or retryable (5xx, transport, timeout); terminal stops the chain, retryable advances to the next provider. The chosen provider id is stored on the message row'sproviderfield and the per-attempt trace lands inmessages.metadata.
Why three paths: caller-pinned (path 1) lets the platform UI probe exactly one provider; brand-Plivo UI (path 2) is a metadata surface for ops; chain (path 3) is the production send path. The chain defaults match the legacy "Plivo for SMS, SES for email" behavior but add automatic failover when the primary is down.
Send modes
- Async (default) — POST returns 202 Accepted with
{message_id, task_id, status:"queued"}; a hanzoai/tasks worker picks up theNotifySendWorkflowand runs the sameDeliveractivity that powers sync. - Sync (
?sync=true) — handler runs the activity inline and blocks on the provider's response. Returns 200 OK with the terminal{message_id, status, error?}shape. - Scheduled (
?send_at=<rfc3339>) — wire reserved; lands once the tasks scheduler API has the matching client surface.
Async fails closed: if the tasks.Dispatcher is not configured
(TASKS_ADDR empty) or not yet started (tasksd unreachable), the
default async path returns 503 Service Unavailable with a hint to
retry shortly or call ?sync=true. There is intentionally no silent
sync fallback — per hanzoai/tasks CONTRACT.md §3, production MUST set
TASKS_ADDR.
hanzoai/tasks wiring
- Namespace:
default(single shared worker for all tenants in this PR — per-org namespacing per CONTRACT.md §6 is the next step once tenant traffic warrants it). - Task queue:
notify-send. - Workflow:
NotifySendWorkflow(internal/tasks/workflow.go). - Activity:
Deliver— idempotent on the message row's terminal states (sent/failed), so tasks-server replays do not re-fire the provider. - Worker lifecycle (Start/Stop) is owned by
cmd/notifyd; theinternal/routespackage only consumes thetasks.Dispatcherinterface.
Phase 1 — notification preferences (paper: notification-preferences-and-channel-routing)
Substrate only. WhatsApp + push are Phase 2/3 — wa and push Channel
values declared so the wire shape is forward-compatible but the router
never returns them.
- Channel router (
internal/router) is a pure function:(prefs, category, now) → []channel. No IO. Table-driven test walks every cell in §3 matrix (11 categories × 5 channels = 55 cells). - Transactional categories (security, legal, money_movement,
trade_execution, account_status, system) always deliver via email
per regulation; SMS/Web come along when the user enabled them in
realtime_channelsAND the §3 matrix allows it. - Marketing categories (promotional, newsletter, product_update, partner_offer, research) deliver only when explicitly opted-in AND not globally muted AND outside the user's quiet hours window (the latter applies only to promotional + partner_offer).
- Default state: every new user starts with every marketing
category OFF; first GET
/v1/notify/preferencesmaterialises the defaults:preferred_channels=[email],realtime_channels=[email], emptymarketing_subscriptions, quiet hours 21:00–08:00 America/New_York. - One-click unsubscribe (
internal/unsubscribe): HMAC-SHA256 overuser_id|category|expires_atwith 30-day TTL. Token IS auth; GET renders no-JS confirmation page, POST flips the bit + writes consent log. Mirror POST/resubscribe/{token}. Tokens are stored withconsumed_atfor idempotency. - List-Unsubscribe headers (
internal/marketing): RFC 8058 + RFC 2369 header builder.List-Unsubscribe: <https-url>, <mailto>List-Unsubscribe-Post: List-Unsubscribe=One-Click. Per-send token, 30-day expiry. Builder is decoupled from the send path — call sites wire it viatasks.Activities.WithMarketing(cfg).
- SES raw-MIME marketing path (
service/amazonses/raw.go+internal/tasks/activities.go): when a send haschannel=emailANDcategory∈ {promotional, newsletter, product_update, partner_offer, research} ANDmarketing.Configis wired,Activities.sendmints an unsubscribe token, persists it intonotification_unsubscribe_tokens, appends a visible footer (HTML<hr><p>…click here</a></p>or plain--\nUnsubscribe: …), and routes throughProviderChain.RunMarketing. Providers that implementRawSender(today:sesAPIChainProvider→amazonses.AmazonSES.SendRaw→SendRawEmail) deliver the request as raw MIME soList-Unsubscribe+List-Unsubscribe-Postreach the recipient untouched. Providers withoutRawSenderfall back to plainSendand the body footer is the only opt-out signal. Header smuggling (CR/LF in extra header values) is rejected at compose time. - STOP keyword webhook (
/v1/notify/sms-inbound): one route accepts Plivo + Twilio (both providers post the same logical payload, just different field names). MatchesSTOP|UNSUBSCRIBE|QUIT|CANCEL|ENDcase-insensitive on the trimmed body; resolves user byprimary_phone(E.164-normalised), setsmarketing_globally_muted=true, zeros every marketing category, writes consent log row (event=opt_out, channel=sms), replies with Twilio/Plivo XML confirmation envelope. - Boot fail-closed:
NOTIFY_UNSUBSCRIBE_SECRETis required; empty in production crashes the binary. Test mode (NOTIFY_TEST_MODE=1) skips the requirement and the unsubscribe routes degrade to 503 while the rest of the surface keeps working. - Admin auth:
NOTIFY_ADMIN_KEYBearer; empty env → fail-closed 401 on every admin route.
Storage — SQLite-only (lockdown)
notifyd uses hanzoai/base exclusively (per-(org, user) SQLite under
--dir). There is no Postgres code path at the source level: pgx only
appears as a transitive indirect in go.mod.
Per the Hanzo PG → SQLite migration plan
(~/work/hanzo/CLAUDE_PG_TO_SQLITE_MIGRATION.md, service #2 — notify),
notifyd ships with a internal/storagelock package that scans the
process env at boot and refuses to start if any of these are set:
DATABASE_URL, POSTGRES_URL, POSTGRES_DSN, POSTGRES_HOST,
NOTIFY_DATABASE_URL, NOTIFY_POSTGRES_URL
This is the canonical anti-regression: notify cannot be re-pinned to
Postgres by env-var override in a deployment manifest. The check is
wired in cmd/notifyd/main.go before base.New(); covered by
internal/storagelock/storagelock_test.go (9 cases including DSN
parse, redaction, and whitespace trimming).
There is no migration CLI for notify because there is no Postgres schema to migrate from — the lockdown IS the deliverable.
Schema invariants
- One Message row per recipient; multi-recipient
tofans out. idempotency_keyis unique within a tenant (partial index).- Templates are versioned per
(tenant, name); only the latestpublishedversion is sendable. - Meter rows are append-only; daily rollup runs out-of-cluster.
Build / boot
GOWORK=off go build ./...
GOWORK=off go test ./...
GOWORK=off go build -o /tmp/notifyd ./cmd/notifyd
TASKS_ADDR="" KMS_ENDPOINT="" /tmp/notifyd serve --http 0.0.0.0:8090 --dir /var/lib/notify
KMS_ENDPOINT="" runs in env-var credentials mode (local dev).
TASKS_ADDR="" disables async — only ?sync=true works; the default
async path returns 503 until TASKS_ADDR is set.
Tags / versioning
v1.0.0— pre-rename casdoor lineage (historical).v1.6.x— Hanzo rebrand on top of casdoor/notify2 import path.v1.7.0+—github.com/hanzoai/notifycanonical module path.v1.1.0-pre*— service scaffold (notifyd + internal/ + pkg/).