Skip to main content

@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).

ExportDescription
MessageBase message with send lifecycle (draft/sending/sent/failed)
EmailEmail with RFC 822 threading, cc/bcc, htmlBody/textBody, folders
SlackMessageSlack message with channelId, slackTs, reactions, blocks
TweetTwitter message with tweetId, retweetCount, likeCount, hashtags
AccountBase account with setCredentials()/getCredentials() via smrt-secrets
EmailAccountEmail account (SMTP/IMAP provider)
SlackAccountSlack workspace account
TwitterAccountTwitter API account
AttachmentMessage attachment
EmailAttachmentEmail-specific attachment
EmailFolderEmail folder/label

Collections​

MessageCollection, EmailCollection, AccountCollection, EmailAccountCollection, AttachmentCollection, EmailAttachmentCollection, EmailFolderCollection

Senders​

Each channel has a dedicated sender implementing MessageSenderInterface.

ExportDescription
EmailSenderSend emails via @happyvertical/email client
SlackSenderSend Slack messages via API
TweetSenderPost tweets via Twitter API
ZulipSenderSend stream or private Zulip messages via bot credentials
TelegramSenderSend 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.