Notifications · How-to guide
Notifications and email setup
Build a durable notification inbox, delivery runtime, and direct authentication email path with the KrakStack notifications package.
@krak-stack/notifications is the default backend for new durable notification systems. It combines package-owned PostgreSQL migrations and CRUD operations with an Effect PersistedQueue, delivery and reminder workers, a due-work scheduler, and optional SES or SMTP transports.
The older shadcn registry items remain available for compatibility. They vendor editable source into an application, but they are not the recommended durable runtime.
Responsibility matrix
| Concern | @krak-stack/notifications owns | The application owns |
|---|---|---|
| Storage | Package migrations; inbox CRUD; preference and suppression CRUD; delivery, attempt, reference, publication, and reminder records | Database provisioning, backups, retention policy, and one shared PgClient layer |
| Delivery runtime | Effect PersistedQueue jobs, retries, reconciliation, scheduler, delivery workers, and reminder workers | Process topology, transport credentials, monitoring, and operational alerts |
| Security and scope | Scope-aware service inputs and Effect schemas | Authentication, authorization, trusted recipient/organization/workspace scope, and tenant isolation |
| Product policy | Transactional-versus-notification preference behavior and one-shot reminder execution | Recipient selection, event policy, recurrence, digest membership and cadence, and legal requirements |
| Content | Persisted rendered snapshots and provider-neutral email payload validation | Templates, rendering, localization, links, and branding |
| Application boundary | Server-side Effect services | HTTP APIs, handlers, atoms, routes, UI state, and cache invalidation |
| Providers | Transport contract plus SESv2 and SMTP adapters | Provider choice, accounts, credentials, sender identities, and deliverability policy |
Never accept a recipient, organization, or workspace identifier from the browser as trusted scope. Derive scope from the authenticated server context before calling the package.
Install the durable package
Install the package with the Effect PostgreSQL driver:
bun add @krak-stack/[email protected] @effect/[email protected] [email protected]Install only the optional email provider used by the application:
# Amazon SESv2
bun add @aws-sdk/client-sesv2
# Or SMTP through Nodemailer
bun add nodemailerThe package exports its service from @krak-stack/notifications, runtime from /runtime, transport contract from /transport, provider-free email schema from /transport/email, and provider adapters from /transport/email-ses and /transport/email-smtp.
Compose one shared PostgreSQL client
Use the same PgClient layer and pool for NotificationService, NotificationRuntime, and the queue. Immediate publication allocates the ledger row, attempt row, and queue job in one contextual SQL transaction; a second client layer would break that atomic boundary.
This complete SES composition runs package migrations, supplies the transport registry, and starts the delivery worker, reminder worker, and built-in scheduler:
import { PgClient } from "@effect/sql-pg";
import { Config, Effect, Layer } from "effect";
import { NotificationService } from "@krak-stack/notifications";
import {
NotificationRuntime,
ReminderHandlerRegistry,
notificationWorkersLayer,
} from "@krak-stack/notifications/runtime";
import { sesEmailTransportRegistryLayer } from "@krak-stack/notifications/transport/email-ses";
const pgClientLayer = Layer.unwrap(
Effect.gen(function* () {
const url = yield* Config.Redacted("DATABASE_URL");
return PgClient.layer({ url });
}),
);
// This adapter layer supplies NotificationTransportRegistry for "email".
const transportRegistryLayer = sesEmailTransportRegistryLayer;
const runtimeLayer = NotificationRuntime.makeLayer({
schedulerIntervalMillis: 10_000,
}).pipe(
Layer.provide(transportRegistryLayer),
Layer.provide(ReminderHandlerRegistry.emptyLayer),
);
export const notificationsLayer = notificationWorkersLayer.pipe(
Layer.provideMerge(runtimeLayer),
// Acquire the migrated service before any worker loop starts.
Layer.provideMerge(NotificationService.layer),
Layer.provide(pgClientLayer),
);Keep notificationsLayer alive in a scoped server or worker runtime. Layer.provideMerge(NotificationService.layer) completes package migrations before acquiring the worker layer. notificationWorkersLayer then forks all three built-in loops: delivery, reminders, and scheduling. Register application reminder handlers with ReminderHandlerRegistry.layer([...]) instead of emptyLayer when one-shot reminders are used.
Custom providers implement NotificationTransport and are collected by channel in a registry:
import { NotificationTransportRegistry } from "@krak-stack/notifications/transport";
const transportRegistryLayer = NotificationTransportRegistry.layer([
emailTransport,
smsTransport,
]);Only one transport can be registered for a channel. Do not register SES and SMTP together under email.
Publish a durable notification
The application selects the recipient, resolves trusted scope and locale, renders the content, and passes immutable snapshots to publish:
import { Effect } from "effect";
import { NotificationService } from "@krak-stack/notifications";
const publishApplicationApproved = Effect.gen(function* () {
const notifications = yield* NotificationService;
return yield* notifications.publish({
idempotencyKey: `application:${applicationId}:approved:v1`,
scope: {
recipientUserId: user.id,
organizationId: organization.id,
workspaceId: workspace.id,
},
eventKey: "application.approved",
eventVersion: 1,
inbox: {
locale: user.locale,
title: rendered.inboxTitle,
description: rendered.inboxDescription,
href: `/applications/${applicationId}`,
metadata: { applicationId },
},
deliveries: [
{
key: "email",
channel: "email",
purpose: "notification",
template: "application-approved@1",
recipientAddress: user.email,
recipientName: user.name,
payloadVersion: 1,
payload: {
subject: rendered.emailSubject,
text: rendered.emailText,
html: rendered.emailHtml,
},
references: [{ namespace: "application", value: applicationId }],
},
],
});
});One delivery row represents one recipient. The email adapter derives the destination from recipientAddress and recipientName; EmailPayloadV1 deliberately has no to, cc, or bcc. Use one publication delivery per recipient.
purpose: "notification" applies preferences and active endpoint suppressions. purpose: "transactional" bypasses preferences, but endpoint suppressions still apply. The application decides which events are legally or operationally transactional.
Configure SES or SMTP
The SES adapter reads these server variables:
SES_REGION=ca-central-1
SES_ACCESS_KEY_ID=...
SES_SECRET_ACCESS_KEY=...
NOTIFICATION_EMAIL_FROM=[email protected]Use sesEmailTransportRegistryLayer from @krak-stack/notifications/transport/email-ses. This release supports explicit SES access keys; AWS default-chain credentials and session tokens are not yet supported.
For SMTP, replace the registry layer in the composition example:
import { smtpEmailTransportRegistryLayer } from "@krak-stack/notifications/transport/email-smtp";
const transportRegistryLayer = smtpEmailTransportRegistryLayer;The SMTP adapter reads the following variables, requires TLS, and verifies the connection when its layer starts:
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=...
SMTP_PASSWORD=...
EMAIL_SENDER_ADDRESS=[email protected]Both adapters accept rendered subject, text, optional html, optional from, and optional replyTo through email payload version 1. At least one of text or html is required.
Scheduled delivery, retries, and reminders
Future deliveries and retries remain in the package ledger until NotificationRuntime.enqueueDue runs. Scheduled delivery therefore requires the built-in scheduler, either through notificationWorkersLayer as shown above or by running NotificationRuntime.runScheduler yourself. Starting only the delivery worker does not enqueue future work.
Provider and reminder-handler side effects are at least once. A process can crash after a provider accepts a message but before the success transaction commits, so the same dispatch generation can run again. Use the stable dispatch or queue job ID for provider idempotency when the provider supports it.
One-shot reminders persist a versioned handler key and payload, then run through the reminder queue. The package owns scheduling, retry, rescheduling, cancellation, and worker execution for that one invocation. The application chooses recurrence rules, creates the next occurrence, and decides which events belong in a digest.
Database and migration ownership
NotificationService.layer runs package domain migrations before exposing the service. The package owns:
notificationsnotification_settingsnotification_deliveriesnotification_delivery_attemptsnotification_delivery_referencesnotification_suppressionsnotification_remindersnotification_publicationskrakstack_notification_migrations
Effect PersistedQueue owns krakstack_notification_jobs and krakstack_notification_jobs_migrations.
Queue startup currently runs Effect DDL when the service or runtime layer constructs its persisted queue. NotificationService.layerWithoutMigrations skips the package's domain migration runner, but it does not disable this queue startup DDL. If deployment roles cannot run DDL, pre-provision both domain and queue objects and validate this behavior against the installed Effect version.
Exclude all package-owned domain, queue, and migration tables from the application's Drizzle migration input and generation. If the application defines read-only mappings for those tables, configure Drizzle Kit so it does not manage them. Do not let two migration systems alter the same tables.
Adopt legacy notification tables
The package migrations are designed to adopt the three tables previously installed by notification-persistence without discarding their data. Use this cutover sequence:
- Back up and preserve
notifications,notification_settings, andnotification_deliveries; do not drop or recreate them. - Stop every application-owned notification relay and worker before starting the package runtime. Old and new workers must never claim the same deliveries concurrently.
- Remove package-owned tables from the application's Drizzle migration input. Keep the existing rows in PostgreSQL, but let the package own their future schema history.
- Start
NotificationService.layerwith the sharedPgClientso its additive migrations can adopt the existing schema before package workers start. - Move application-specific delivery correlations to
notification_delivery_referenceswith stable namespaces. The migration imports compatiblenotification_delivery_correlationscolumns forapplication,opportunity, andorganization; migrate any other correlation shape explicitly. - Validate counts and statuses, then start
notificationWorkersLayerand monitor the first scheduler and worker passes.
During adoption, legacy processing deliveries that have no current Effect queue job become due retrying rows when attempt budget remains, or failed rows when it is exhausted. Legacy claims are cleared. Disabled rows from a compatible email_preferences table are imported as email suppressions for purpose: "notification"; enabled rows do not need an override because notification delivery defaults to enabled.
Use direct mode for authentication mail
KrakAuth-like OTP, verification, and password-reset email often needs a simple inline provider call rather than an inbox or durable ledger. Direct mode reuses the same transport registry without PostgreSQL or a queue:
import { Effect, Layer } from "effect";
import {
DirectNotificationDispatcher,
dispatchDirect,
} from "@krak-stack/notifications/direct";
import { sesEmailTransportRegistryLayer } from "@krak-stack/notifications/transport/email-ses";
const directEmailLayer = DirectNotificationDispatcher.layer.pipe(
Layer.provide(sesEmailTransportRegistryLayer),
);
const sendPasswordReset = dispatchDirect({
scope: {
recipientUserId: user.id,
organizationId: null,
workspaceId: null,
},
eventKey: "auth.password-reset",
eventVersion: 1,
channel: "email",
template: "password-reset@1",
recipientAddress: user.email,
recipientName: user.name,
payloadVersion: 1,
payload: {
subject: rendered.subject,
text: rendered.text,
html: rendered.html,
},
}).pipe(Effect.provide(directEmailLayer));Direct mode has no persistence, retry scheduling, inbox record, delivery history, preference lookup, or suppression lookup. The calling authentication flow must decide how a provider failure affects the request and must avoid claiming durable delivery guarantees.
Keep application APIs and UI scoped
The package intentionally ships no HTTP group, handlers, atoms, routes, or UI. Wrap NotificationService methods in application APIs that derive the actor and scope from trusted authentication context. Return only records authorized for that scope, and invalidate the matching client query after mutations.
For a source-installed UI, use the backend-neutral menu:
bunx shadcn@latest add @krak-stack/notification-menuNotificationMenu receives already-authorized records and reports IDs through controlled callbacks. The application maps package inbox records to the component, owns navigation, and supplies localized notification content and product branding.
Legacy source-vendored options
service-notificationis a direct compatibility dispatcher for applications already using top-level channel messages. Its optional application store hook does not provide package migrations, a persisted queue, reconciliation, or workers.notification-channel-email-sesis the matching legacy SES channel. New package users import@krak-stack/notifications/transport/email-sesinstead.notification-persistenceinstalls the legacy three-table Drizzle definitions and codecs as editable application source. New durable applications should not install it; use@krak-stack/notificationsand its migrations.notification-menuremains a supported, backend-neutral presentation component.
Do not compose the legacy dispatcher or application-owned worker with the new package runtime for the same event. Choose one delivery owner during migration to avoid duplicate sends.
Production checklist
- Use one shared
PgClientpool for service, runtime, and queues. - Run package migrations before package workers and exclude package tables from Drizzle migrations.
- Run the built-in scheduler wherever scheduled delivery or retries are enabled.
- Treat provider and reminder side effects as at-least-once.
- Authorize every inbox, preference, suppression, delivery, and reminder operation at the server boundary.
- Render and localize snapshots before publication; keep template versions in application source.
- Monitor terminal failures, stale-job reconciliation, queue depth, provider limits, and sender reputation.
- Test English and French content, migration adoption, and crashes around provider acceptance.
- Never log credentials or sensitive message bodies.