@happyvertical/smrt-messages
Multi-channel messaging with STI hierarchies for Email, Slack, and Twitter. Credential encryption via smrt-secrets and retry-aware send lifecycle.
Overview
smrt-messages provides unified multi-channel messaging using single-table
inheritance on both messages and accounts. Email, Tweet, and SlackMessage all share the messages table, while EmailAccount, SlackAccount, and TwitterAccount share
the accounts table. Credentials are stored exclusively via smrt-secrets envelope encryption.
Installation
npm install @happyvertical/smrt-messages
# or
pnpm add @happyvertical/smrt-messagesDepends on @happyvertical/smrt-core, @happyvertical/smrt-secrets (credential encryption), and @happyvertical/email (SMTP / IMAP client).
Quick Start
1. Create an email account with encrypted credentials
import {
Email, EmailCollection,
EmailAccount, EmailAccountCollection,
EmailSender, MessageCollection,
} from '@happyvertical/smrt-messages';
const accounts = new EmailAccountCollection(db);
const account = await accounts.create({
name: 'Support',
providerType: 'smtp',
});
// Credentials stored via smrt-secrets envelope encryption
await account.setCredentials({
host: 'smtp.example.com',
user: 'support@example.com',
pass: process.env.SMTP_PASSWORD,
});2. Send an email (and retry on failure)
const emails = new EmailCollection(db);
const email = await emails.create({
accountId: account.id,
subject: 'Welcome',
toAddresses: JSON.stringify([{ address: 'user@example.com' }]),
textBody: 'Thanks for signing up!',
});
// Send lifecycle: draft -> sending -> sent (or failed)
const result = await email.send();
// Retry respects maxRetries budget and increments retryCount
if (!result.success) {
await email.retrySend();
}3. Query messages across all channels
const messages = new MessageCollection(db);
const recent = await messages.list({
orderBy: 'createdAt DESC',
limit: 20,
});STI hierarchies
Messages (share messages table)
| Type | STI discriminator | Key fields |
|---|---|---|
Message | (base) | accountId, threadId, subject, body, fromAddress, toAddresses, sendStatus, retryCount |
Email | @happyvertical/smrt-messages:Email | messageId (RFC 822), inReplyTo, ccAddresses, bccAddresses, htmlBody, textBody, folderId, labels, headers |
Tweet | @happyvertical/smrt-messages:Tweet | tweetId, retweetCount, likeCount, mediaUrls, hashtags, mentions |
SlackMessage | @happyvertical/smrt-messages:SlackMessage | channelId, slackTs, slackThreadTs, reactions, blocks |
Accounts (share accounts table)
| Type | STI discriminator | Notes |
|---|---|---|
Account | (base) | providerType, credentialSecretId, isActive, lastSyncAt, settings |
EmailAccount | @happyvertical/smrt-messages:EmailAccount | SMTP / IMAP provider-specific fields + sync methods |
SlackAccount | @happyvertical/smrt-messages:SlackAccount | Slack workspace connection |
TwitterAccount | @happyvertical/smrt-messages:TwitterAccount | Twitter API connection |
Credential security
Account credentials are stored via credentialSecretId pointing into smrt-secrets envelope encryption (AMK → TDEK → secret). Never
store passwords as plain fields.
// Always use setCredentials / getCredentials
await account.setCredentials({
host: 'smtp.example.com',
user: 'support@example.com',
pass: process.env.SMTP_PASSWORD,
});
const creds = await account.getCredentials();Send lifecycle and senders
message.send() resolves the account, picks a provider-specific sender, and drives
the status transition. Each channel has a dedicated sender implementing MessageSenderInterface:
| Sender | Description |
|---|---|
EmailSender | Send emails via the @happyvertical/email client |
SlackSender | Post Slack messages via API |
TweetSender | Post tweets via the Twitter API |
retryCount is incremented on every retry and capped by maxRetries. A
failed send leaves the message in sendStatus = 'failed' for inspection.
Inbound email sync (IMAP)
Sending is only half the story. EmailAccount.syncFrom() pulls messages down from the server and persists them as Email rows. It opens an @happyvertical/email client (resolving IMAP credentials from smrt-secrets when credentialSecretId is set),
ensures an EmailFolder exists for each requested folder, fetches messages, and
upserts each one — skipping messages already stored unless fullSync is set.
const account = await accounts.get({ id: accountId });
const result = await account.syncFrom({
folders: ['INBOX', 'Sent'], // defaults to ['INBOX']
since: new Date('2026-01-01'), // optional server-side date filter
before: new Date('2026-06-01'),
batchSize: 100, // messages fetched per folder
fullSync: false, // true re-imports already-stored messages
onProgress: (s) => console.log(s.folder, s.processed, '/', s.total),
});
// SyncResult:
// {
// folders: string[], // folders actually synced
// messagesProcessed: number,
// messagesDownloaded: number,
// messagesSkipped: number, // already-stored, fullSync off
// errors: Error[],
// duration: number, // ms
// }Existing messages are matched by RFC 822 messageId per account, so re-running syncFrom() is idempotent: unchanged messages land in messagesSkipped rather than being duplicated. syncAll() runs the same
flow across every configured account.
Attachments
Attachments now own a single messageId FK rather than the old emailId. The deprecated emailId getter / setter is preserved as a
compatibility wrapper so legacy callers keep working — but new code should reference messageId.
const attachment = new Attachment({
messageId: email.id, // not emailId
filename: 'report.pdf',
mimeType: 'application/pdf',
assetId: pdfAsset.id, // stored via smrt-assets
});
await attachment.save();Email filtering (Whitelist / Blacklist)
import {
Whitelist, WhitelistCollection,
Blacklist, BlacklistCollection,
} from '@happyvertical/smrt-messages';
const whitelist = new WhitelistCollection(db);
await whitelist.create({ address: 'trusted@example.com' });
const blacklist = new BlacklistCollection(db);
await blacklist.create({ address: 'spam@example.com' });Svelte 5 Components
- EmailAccountManager: admin UI for managing email account connections and credentials
- EmailFilterManager: admin UI for managing whitelist / blacklist rules
- MessageCard, MessageList: display message summaries
- ComposeForm, ReplyForm, ForwardForm: message composition
- ThreadView, MessageDetail: conversation display
- FolderNav, MessageFilters: navigation and filtering
- AccountCard, AccountList, AccountAvatar: account management
- AttachmentChip, AttachmentUpload: attachment handling
- SendStatusBadge, MessageStatusIndicator, MessageTypeBadge: status display
- MessageToolbar, RecipientInput: toolbar and input components
import {
EmailAccountManager,
EmailFilterManager,
MessageCard,
MessageList,
} from '@happyvertical/smrt-messages/svelte';Collections
// Base collections (query across all channels)
MessageCollection
AccountCollection
AttachmentCollection
// Email-specific collections
EmailCollection
EmailAccountCollection
EmailAttachmentCollection
EmailFolderCollection
// Email filtering
WhitelistCollection
BlacklistCollectionBest Practices
DOs
- Always use
setCredentials()/getCredentials()for account credentials - Use JSON address helpers:
getToAddresses(),getCcAddresses() - Handle send failures with
retrySend()(respectsmaxRetries) - Use
MessageCollectionto query across all channel types - Reference attachments via
messageId
DON'Ts
- Don't store passwords directly — always use smrt-secrets encryption
- Don't modify JSON fields directly — use accessor methods (
getX()/setX()) - Don't override
toJSON()— usetransformJSON() - Don't use the deprecated
emailIdon Attachment in new code - Don't run concurrent syncs on the same account