@happyvertical/smrt-messages
Unified multi-channel messaging with persona routes, encrypted credentials, and provider adapters. Outbound delivery supports email, Zulip, and Telegram; SMS is a reserved channel ready for a provider adapter.
Installation
pnpm add @happyvertical/smrt-messages
Usage
import {
AttachmentCollection,
Email, EmailCollection,
EmailAccount, EmailAccountCollection,
EmailSender,
MessageCollection,
} from '@happyvertical/smrt-messages';
// Create an email account with encrypted credentials
const accounts = await EmailAccountCollection.create({ db });
const account = await accounts.create({
name: 'Support',
providerType: 'smtp',
});
await account.setCredentials({
host: 'smtp.example.com',
user: 'support@example.com',
pass: process.env.SMTP_PASSWORD,
});
// Send an email through the send lifecycle
const emails = await EmailCollection.create({ db });
const email = await emails.create({
accountId: account.id,
subject: 'Welcome',
toAddresses: JSON.stringify([{ address: 'user@example.com' }]),
textBody: 'Thanks for signing up!',
});
const result = await email.send();
// sendStatus transitions: draft -> sending -> sent (or failed)
// Retry a failed message (respects maxRetries budget)
if (!result.success) {
await email.retrySend();
}
// Attach files: stored `Attachment` rows are read through
// `Attachment.readContent()` at send time, and bytes rendered on demand go in
// `send({ attachments })`. Both reach the transport together; a stored file
// that cannot be read fails the send (sendStatus 'failed', sendError set)
// instead of being dropped.
const order = await emails.create({
accountId: account.id,
subject: 'Purchase order PO-1042',
toAddresses: JSON.stringify([{ address: 'orders@vendor.example' }]),
textBody: 'Please find our purchase order attached.',
hasAttachments: true,
});
const attachments = await AttachmentCollection.create({ db });
await attachments.create({
messageId: order.id,
filename: 'PO-1042.pdf',
contentType: 'application/pdf',
size: pdfBytes.length,
filePath: '/data/po/PO-1042.pdf',
});
await order.send({
attachments: [
{ filename: 'terms.txt', contentType: 'text/plain', content: termsBytes },
],
});
// Query messages across all channels via base collection
const messages = await MessageCollection.create({ db });
const recent = await messages.list({ orderBy: 'createdAt DESC', limit: 20 });
API
Models (STI Hierarchy)
Messages and accounts each use single-table inheritance. STI discriminators use qualified names (e.g., @happyvertical/smrt-messages:Email).
| Export | Description |
|---|---|
Message | Base message with send lifecycle (draft/sending/sent/failed) |
Email | Email with RFC 822 threading, cc/bcc, htmlBody/textBody, folders |
SlackMessage | Slack message with channelId, slackTs, reactions, blocks |
Tweet | Twitter message with tweetId, retweetCount, likeCount, hashtags |
Account | Base account with setCredentials()/getCredentials() via smrt-secrets |
EmailAccount | Email account (SMTP/IMAP provider) |
SlackAccount | Slack workspace account |
TwitterAccount | Twitter API account |
Attachment | Message attachment |
EmailAttachment | Email-specific attachment |
EmailFolder | Email folder/label |
Collections
MessageCollection, EmailCollection, AccountCollection, EmailAccountCollection, AttachmentCollection, EmailAttachmentCollection, EmailFolderCollection
Senders
Each channel has a dedicated sender implementing MessageSenderInterface.
| Export | Description |
|---|---|
EmailSender | Send emails via @happyvertical/email client |
SlackSender | Send Slack messages via API |
TweetSender | Post tweets via Twitter API |
ZulipSender | Send stream or private Zulip messages via bot credentials |
TelegramSender | Send Telegram bot messages to a chat/topic |
Persona-scoped outbound messaging
MessagingSettingsService is the permission-gated server boundary for saving
accounts, write-only credentials, destinations, and persona routes.
PersonaMessagingService selects the highest-priority matching route, persists
the message, then runs the normal send lifecycle.
Schema changes are manifest-driven. After upgrading an application, run
smrt db:diff and smrt db:migrate to add the account/message columns and the
messaging_endpoints / persona_message_routes tables.
When constructing MessagingSettingsService, provide
resolvePersonaTenantId(personaId) from the host's persona collection. Route
writes fail closed unless it proves the persona belongs to the same tenant.
import {
messagingPrincipalFromPermissions,
PersonaMessagingService,
} from '@happyvertical/smrt-messages';
const messaging = new PersonaMessagingService({
db,
tenantId,
principal: messagingPrincipalFromPermissions(['messages.send'], { tenantId }),
});
await messaging.send({
personaId,
channel: 'telegram', // optional; default route otherwise
body: 'The scheduled report is ready.',
});
// Email routes also take send-time attachments; any other route rejects them.
await messaging.send({
personaId,
channel: 'email',
subject: 'Weekly report',
body: 'The report is attached.',
attachments: [
{ filename: 'report.pdf', contentType: 'application/pdf', content: pdf },
],
});
Agents receive the persona-fixed tool from @happyvertical/smrt-personas:
const tool = createPersonaMessagingTool({ db, tenantId, personaId });
The tool only accepts message content and route intent. Persona, account, endpoint, and credential ids remain trusted server state.
Apps can render MessagingSettingsPanel from
@happyvertical/smrt-messages/svelte using the sanitized view returned by
MessagingSettingsService.list() and wiring its save callbacks back to the
service.
In-app notifications
UserNotification stores one notification per person with read state;
UserNotificationService creates, lists, and updates them for one recipient.
import { UserNotificationService } from '@happyvertical/smrt-messages';
const notifications = new UserNotificationService({ db });
// Idempotent per (tenant, recipient, sourceRef).
await notifications.notify({
tenantId,
recipientUserId: post.createdByUserId,
kind: 'social-post.failed',
title: "A post to X didn't go out",
href: `/sites/${slug}/social-posts`,
severity: 'error',
sourceRef: `social-post:${post.id}:failed`,
});
const items = await notifications.listForUser(userId, { tenantIds });
const unread = await notifications.countUnread(userId, { tenantIds });
await notifications.markRead(userId, [id], { tenantId });
await notifications.dismiss(userId, [id], { tenantId });
await notifications.markAllRead(userId, { tenantIds });
The generated REST/MCP/CLI surfaces are off; expose host routes that pass the
signed-in user's id. The table is new: run db:migrate.
Notification email outbox
NotificationOutboxService sends one email per event and recipient from a
durable, leased outbox. The host resolves recipients and the sender account;
callers pass a user id and a host-relative link path, never an address.
import { NotificationOutboxService } from '@happyvertical/smrt-messages';
import '@happyvertical/smrt-messages/providers/email'; // default transport
const outbox = new NotificationOutboxService({
db,
linkBaseUrl: 'https://app.example.com',
resolveRecipient: async ({ tenantId, recipientUserId }) =>
(await isMember(tenantId, recipientUserId))
? { address: await emailOf(recipientUserId) }
: null, // denied
resolveSenderAccountId: ({ tenantId }) => senderAccountIdFor(tenantId),
});
// Idempotent per (tenant, recipient, email, sourceRef).
await outbox.enqueueEmail({
tenantId,
recipientUserId,
sourceRef: `project-question:${question.id}:asked`,
kind: 'project-question.asked',
subject: 'A question is waiting for you',
text: question.prompt,
path: `/portal/projects/${projectId}/questions#question-${question.id}`,
});
// From a scheduled job; safe to run concurrently and after a crash.
await outbox.dispatchDue({ limit: 25 });
Outcomes the provider may have seen but did not confirm become uncertain
and wait for reconcile(). See
agents/notification-outbox.md for the lease,
retry, and reconciliation policy. The table is new: run db:migrate.
Per-recipient opt-out
NotificationOptOutService is the opt-out ledger (notification_opt_outs)
for the outbox's isSuppressed hook. A scope is * or a host key such as
project:<uuid>.
import {
NotificationOptOutService,
optOutSuppression,
} from '@happyvertical/smrt-messages';
const optOuts = new NotificationOptOutService({ db });
// Idempotent; with a principal only the recipient may change their own row,
// in the tenant the principal is authenticated in.
await optOuts.optOut({
tenantId,
recipientUserId,
channel: 'email',
scope: `project:${projectId}`,
principal: { userId: session.userId, tenantId },
});
// Pass to the outbox: enqueue and dispatch both honour opt-outs.
const isSuppressed = optOutSuppression(optOuts, async (request) => {
// The host maps its own event to a scope; null = not opt-out-able.
// Here a question's sourceRef is resolved to its project by a host lookup.
const projectId = await projectIdForSourceRef(request.tenantId, request.sourceRef);
return projectId ? `project:${projectId}` : null;
});
The table is new: run db:migrate.
Credential Security
Account credentials are stored via credentialSecretId pointing to smrt-secrets envelope encryption. Use account.setCredentials() and account.getCredentials() -- never store passwords as plain fields.
Key Types
MessageType, ProviderType, SendStatus, SendMessageOptions, SendEmailOptions, SendSlackOptions, SendTweetOptions, MessageSendResult, MessageSenderInterface, SyncOptions, SyncResult, SyncProgress
Dependencies
@happyvertical/smrt-core-- ORM and code generation@happyvertical/smrt-secrets-- credential encryption (envelope encryption)@happyvertical/smrt-tenancy-- optional multi-tenant scoping@happyvertical/smrt-types-- shared type definitions@happyvertical/email-- SMTP/IMAP email client- Peer:
@happyvertical/smrt-svelte(optional)
Contributor guide
See AGENTS.md for package architecture, invariants, validation,
and contributor guidance.
Notification bell and mailbox recipes
Messages exports NotificationsRecipe (messages.notifications) and MailboxRecipe
(messages.mailbox). The mailbox playground offers an interactive fictional inbox;
its compose action reports a mock send and never calls a mail transport.
For a server-rendered authorized host, construct createUserNotificationBellProvider
from @happyvertical/smrt-messages/svelte with a UserNotificationService, the signed-in
userId, and the authorized tenantIds. Pass that provider to NotificationBell
from @happyvertical/smrt-svelte/notifications. Browser hosts implement the same
provider interface over authenticated server endpoints; never send database services
to a browser. Replace the provider when the user or tenant context changes. Optional
subscribe connects a host-owned transport; the UI unsubscribes on disposal.
The Messaging feature card offers mailbox and notification controls independently.
Mailbox navigation includes a stable Failed sends entry filtered by sendStatus = failed.
Notifications deliberately declare no generic model navigation: recipient-scoped
provider access remains required.
The mailbox route surface resolves MessageList; the host supplies authorized
messages, account DTOs, and callbacks. Its email-inbox provider lists the
implemented IMAP, POP3, and Gmail readers; the optional email-send provider
lists SMTP and Gmail sending. Import @happyvertical/smrt-messages/providers/email
on the server and keep credentials in the existing secret-backed account setup.
Provider metadata names implementations, not automatic connections or credentials.
The MailboxDemo playground remains self-contained and every send is mocked.
Its JSON demo seed is a fictional MessageList props payload, never a live account.
The notification surface places NotificationBell at header.end. The required
user-notification-service implementation maps to createUserNotificationBellProvider
and still needs a host-authorized signed-in user and tenant set. Browser hosts
bridge that service through authenticated endpoints. The JSON items seed is
presentation data: a demo host wraps it in an in-memory NotificationProvider;
it must never route demo mutations to the production notification service.
All these recipes use runtime: both because the UI is browser-safe while live
mail and notification persistence remain server operations.