The package held procedure-name constants and a note that the real ZAP surface would land 'once the corresponding hanzoai/zap server-side helpers stabilize'. That premise is false: zip's listenOn builds ONE handler and serves it across every Listen address, so cloud already answers /v1/notify/* on its ZAP listener (:9653) as well as its HTTP one. There was no server surface left to add, and the constants named procedures nothing ever dispatched. Nothing imported it — and being internal/, nothing outside the module could. Co-authored-by: Hanzo Dev <[email protected]>
13 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
└── 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
KMS is reached over its HTTP secrets API (/v1/kms/orgs/{org}/secrets/...).
internal/kmsbridge is the ONE place that resolves a secret; its
NormalizeEndpoint is the single seam that maps the fleet's mesh-style
zap://kms.hanzo.svc:9999 endpoint onto KMS's HTTP service (KMS has no
ZAP listener, so the scheme becomes http and the :9999 mesh port is
dropped). KMS_ENDPOINT may be given as zap://, http://, https://,
or a bare host — all normalize to the same HTTP base URL.
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 Hanzo default 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 → twilio_email email_otp → twilio_email email_marketing → twilio_email, sendgridEach provider's credentials are read from
brand/<slug>/<provider>/<key-kebab>with brand→hanzo 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 are "Plivo for SMS, Twilio-native for email" — email rides
Twilio's Email API (comms.twilio.com/v1/Emails, Basic auth on the
SAME account creds as Twilio SMS), with sendgrid the marketing-class
fallback. SES was removed: one cloud (Twilio) backs both channels.
The chain adds 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 thehanzotenant traffic scales). - 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; the headers ride email via the chain'sRawSendercapability, whichtwilio_emailimplements (Twilio's Email API carries a headers map).
- 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.
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 lineage (historical).v1.6.x— Hanzo rebrand, still on the pre-rename import path.v1.7.0+—github.com/hanzoai/notifycanonical module path.v1.1.0-pre*— service scaffold (notifyd + internal/ + pkg/).