@venturekit/notify
v0.0.0-dev.20260507015944
Published
Transactional notifications for VentureKit — email (SES), WhatsApp (Meta Cloud), with a Postgres outbox + cron dispatcher.
Downloads
199
Maintainers
Readme
@venturekit/notify
Transactional notifications for VentureKit applications — email (AWS SESv2), WhatsApp (Meta Cloud API), with SMS / push as placeholders. Backed by a Postgres outbox + auto-provisioned cron dispatcher Lambda.
Pre-release — API may change before 1.0.
What you get
Declare one notify intent in vk.config.ts and vk deploy provisions:
- SES email identity (single-address or whole-domain, with DKIM signing when domain-based)
- SES configuration set with bounce / complaint / delivery / open / click events streaming into an SNS topic
- SNS bounce-handler Lambda that records every event in
notification_event_logand auto-suppresses hard-bounced addresses - Postgres outbox schema (
notifications,notification_preferences,notification_event_log) shipped with the package and merged into the project's migration runner - EventBridge cron + dispatcher Lambda that drains the outbox once per minute (configurable) and calls each row's provider
- Secrets Manager secret for the Meta WhatsApp API token (operator pastes the value after first deploy)
- IAM grants (
ses:SendEmail,ses:SendRawEmail,secretsmanager:GetSecretValue) scoped to the resources above on the shared Lambda execution role
Application code only touches notify.enqueue({...}). Retry, preference filtering, address suppression, bounce processing, and template rendering all happen behind the scenes.
Quick start
1. Declare the intent
// vk.config.ts
import { defineVenture } from '@venturekit/infra';
export default defineVenture({
base,
security,
databases: [{ id: 'main', type: 'postgres', name: 'app' }],
notify: [
{
id: 'main',
channels: ['email', 'whatsapp'],
defaultFrom: '[email protected]',
domain: 'app.com', // optional: SES verifies the whole domain
hostedZoneId: 'Z123…', // optional: auto-publish DKIM CNAMEs in Route53
whatsapp: {
phoneNumberId: '105954992345678', // from Meta Business Suite
},
},
],
envs: { dev, prod },
});2. Build the runtime client
// src/lib/notify.ts
import {
createNotifyClient,
createPostgresNotifyStore,
createSesEmailProvider,
createMetaWhatsAppProvider,
createSecretsTokenResolver,
} from '@venturekit/notify';
import { findUserById } from '@/db/repos/users';
export const notify = createNotifyClient({
store: createPostgresNotifyStore(),
defaultFrom: process.env.VENTURE_NOTIFY_FROM,
providers: [
createSesEmailProvider({
region: process.env.AWS_REGION!,
configurationSetName: process.env.VENTURE_NOTIFY_CONFIG_SET,
}),
createMetaWhatsAppProvider({
phoneNumberId: process.env.VENTURE_NOTIFY_WHATSAPP_PHONE_ID!,
tokenResolver: createSecretsTokenResolver({
secretArn: process.env.VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET!,
}),
}),
],
// Map user ids -> channel-specific addresses. Phase 1 expects the
// consumer to own this lookup; future phases may add a default that
// reads from a user table convention.
addressResolver: async (userId, channel) => {
const user = await findUserById(userId);
if (channel === 'email') return user?.email ?? null;
if (channel === 'whatsapp') return user?.whatsappNumber ?? null;
return null;
},
});
// Templates live in code in Phase 1. Register them at module load so
// the dispatcher's first invocation already sees them.
notify.registerTemplate({
key: 'application_registered',
channel: 'email',
category: 'transactional_application',
render: ({ groupName }: { groupName: string }) => ({
subject: `Inscription confirmée: ${groupName}`,
text: `Votre inscription au groupe ${groupName} est confirmée.`,
html: `<p>Votre inscription au groupe <b>${groupName}</b> est confirmée.</p>`,
}),
});VentureKit's auto-provisioned dispatcher imports this module via VENTURE_NOTIFY_CLIENT_MODULE (default ./lib/notify.js). Override the env var per intent if your project uses a different path.
3. Send
// src/routes/applications/post.ts
import { notify } from '@/lib/notify';
await notify.enqueue({
recipientUserId: ctx.user.id,
channel: 'email',
templateKey: 'application_registered',
payload: { groupName: 'Île-de-France #4' },
relatedKind: 'application',
relatedId: applicationId,
});The handler returns synchronously after the row hits the outbox. The dispatcher cron picks it up on the next tick (≤ 1 minute by default) and calls SES.
Architecture
┌────────────────────┐ ┌──────────────────────┐ ┌──────────────────┐
│ Route handler │ │ Outbox (Postgres) │ │ Dispatcher cron │
│ notify.enqueue() │───►│ notifications │◄───│ every 1 minute │
└────────────────────┘ │ + preferences │ │ drain pending │
│ + event_log │ │ call provider │
└──────────────────────┘ │ mark sent/fail │
└────┬─────────────┘
│
┌────────────────────────────────────────────┴─────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ AWS SESv2 │ │ Meta Graph API │
│ SendEmail │ │ /messages │
└──────────┬───────────┘ └──────────────────────┘
│
▼
┌──────────────────────┐
│ SES events SNS │
│ bounce / complaint │──────► Bounce handler Lambda ──► event_log
└──────────────────────┘ │
▼
Address suppressed
in future sendsThree persistent failure modes are recorded distinctly:
| Status | Meaning | Retried? |
| --- | --- | --- |
| pending | Awaiting next cron tick. | Yes — claimed by claimPending(). |
| sent | Provider accepted delivery. | No. May still flip to bounced. |
| failed | Provider rejected (hard error) or transient retries exhausted. | Manually via admin retry. |
| bounced | Bounce / complaint event arrived after sent. | Address auto-suppressed. |
| cancelled | Suppressed at enqueue (opt-out, address suppression). | No. |
Channels
| Channel | Phase 1 provider | Real impl? |
| --- | --- | --- |
| email | AWS SESv2 (@aws-sdk/client-sesv2, lazy-loaded) | ✅ |
| whatsapp | Meta Cloud API (Graph /messages, fetch) | ✅ |
| sms | noop-sms placeholder — logs and returns success | ❌ — register a real provider in your createNotifyClient({ providers }) array. |
| push | noop-push placeholder | ❌ — same as above. |
The dispatcher uses whichever provider is registered last for a given channel — wire your own SNS / Twilio / Pinpoint provider by passing it in the providers array; it overrides the noop.
Outbox tracking
Phase 1 persistence is always Postgres. NotifyIntent.tracking exists in the type for forward-compatibility but only 'postgres' is implemented. Auto-applied migrations create:
notifications— outbox / audit lognotification_preferences— per-(user, channel, category) opt-innotification_event_log— bounce / complaint / delivery / open / click events
Migrations live under node_modules/@venturekit/notify/migrations/vk_notify_*.sql. The infra package merges them into the project's CodeBuild migration runner asset on every vk deploy. Filename collisions with project-shipped migrations throw at synth time — VentureKit packages prefix their files with vk_<package>_ to avoid that hazard.
Templates
Phase 1 keeps templates in code. Each template is a typed (payload) -> { subject?, text, html?, whatsapp? } function registered at boot:
notify.registerTemplate<{ name: string }>({
key: 'welcome',
channel: 'email',
category: 'transactional_account',
render: ({ name }) => ({
subject: `Welcome, ${name}`,
text: `Hi ${name}, welcome aboard.`,
html: `<p>Hi <b>${name}</b>, welcome aboard.</p>`,
}),
});Templates without a category bypass the per-user opt-out check — useful for password resets, security alerts, etc., that the user can never opt out of.
WhatsApp template messages need Meta-pre-approved templates and use the RenderedMessage.whatsapp block:
notify.registerTemplate({
key: 'application_received',
channel: 'whatsapp',
category: 'transactional_application',
render: ({ name, groupName }: { name: string; groupName: string }) => ({
text: `Hi ${name}, your application to ${groupName} is in.`,
whatsapp: {
metaTemplateName: 'application_received_v1',
languageCode: 'fr',
components: [
{
type: 'body',
parameters: [
{ type: 'text', text: name },
{ type: 'text', text: groupName },
],
},
],
},
}),
});Phase 2 will introduce DB-backed templates with admin UI editing — see the project roadmap.
Admin / inbox helpers
@venturekit/notify/admin exposes route-helper functions consumers wire into their own handlers (no automatic mounting — pick your own auth/scope conventions):
// src/routes/admin/notifications/list/get.ts
import { handler } from '@venturekit/runtime';
import { adminListNotifications } from '@venturekit/notify/admin';
import { notify } from '@/lib/notify';
export const main = handler(async (ctx) => {
ctx.requireScope('admin.notifications.read');
const url = new URL(ctx.request.url);
return adminListNotifications(notify, {
limit: Number(url.searchParams.get('limit') ?? 50),
offset: Number(url.searchParams.get('offset') ?? 0),
status: url.searchParams.get('status') as never,
search: url.searchParams.get('q') ?? undefined,
});
});Available helpers:
adminListNotifications(client, options)— paginated list with status / channel / template / search filtersadminGetNotification(client, id)adminSendNotification(client, input)— equivalent toclient.enqueue(...), separate name for audit loggingadminRetryNotification(client, id)— flipsfailed/bouncedback topendingadminCancelNotification(client, id)— cancels apendingrow before dispatchadminClearSuppression(client, channel, address)— lifts a bounce / complaint suppressionlistUserPreferences(client, userId)/upsertUserPreference(client, ...)— per-user opt-in managementlistNotificationsForUser(client, userId, options)— buyer-portal inbox view
Local dev (vk dev)
Declaring a notify intent with the email channel adds a MailHog service to ~/.vk/docker-compose.yml:
- SMTP at
localhost:1025 - Web inbox at
http://localhost:8025
VK's local-mode runtime injects VENTURE_NOTIFY_DRIVER=local so the email provider falls through to the noop / log adapter — Phase 1 doesn't auto-route through MailHog yet (planned for Phase 2). Until then operators can spin a custom email provider against MailHog by passing endpoint: 'http://localhost:1025' in createSesEmailProvider (the SES SDK lets you point at any SMTP, MailHog accepts).
Environment variables
The infra package injects these on every Lambda when a notify intent is declared:
| Variable | Source |
| --- | --- |
| VENTURE_NOTIFY_FROM | First notify intent's defaultFrom |
| VENTURE_NOTIFY_CONFIG_SET | First notify intent's SES configuration set name |
| VENTURE_NOTIFY_EVENTS_TOPIC_ARN | First notify intent's SES events SNS topic ARN |
| VENTURE_NOTIFY_WHATSAPP_PHONE_ID | First notify intent's whatsapp.phoneNumberId |
| VENTURE_NOTIFY_WHATSAPP_API_VERSION | First notify intent's whatsapp.apiVersion |
| VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET | Secrets Manager ARN for the WhatsApp token |
| NOTIFY_<ID>_FROM / NOTIFY_<ID>_CONFIG_SET / NOTIFY_<ID>_EVENTS_TOPIC_ARN | Per-id overrides for projects with multiple intents |
| VENTURE_NOTIFY_BATCH_SIZE | Dispatcher cron — overrides default 50 |
| VENTURE_NOTIFY_CLIENT_MODULE | Path to the consumer's notify-client module (default ./lib/notify.js) |
Migrating from @venturekit-pro/comms
The 0.x cut of @venturekit-pro/comms shipped email + push + SMS + WhatsApp + chat in one package. The split:
- Notifications (this package, free tier): all four channels (email / WhatsApp / SMS / push), templates, preferences, outbox, dispatcher.
- Chat (
@venturekit-pro/chat, Pro): rooms, participants, messages, presence — unchanged API.
Migrate with one install:
pnpm add @venturekit/notify @venturekit-pro/chat
pnpm remove @venturekit-pro/commsThen update imports — the chat manager API is unchanged, the notify API is intent-driven (declare in vk.config.ts, get auto-provisioned SES + outbox + cron).
License
Apache-2.0
