@happyvertical/smrt-messages

Multi-channel messaging with STI hierarchies for Email, Slack, and Twitter. Credential encryption via smrt-secrets and retry-aware send lifecycle.

v0.29.34EmailSlackTwitterEncrypted Credentials

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

bash
npm install @happyvertical/smrt-messages
# or
pnpm add @happyvertical/smrt-messages

Depends 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

typescript
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)

typescript
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

typescript
const messages = new MessageCollection(db);
const recent = await messages.list({
  orderBy: 'createdAt DESC',
  limit: 20,
});

STI hierarchies

Messages (share messages table)

TypeSTI discriminatorKey fields
Message(base)accountId, threadId, subject, body, fromAddress, toAddresses, sendStatus, retryCount
Email@happyvertical/smrt-messages:EmailmessageId (RFC 822), inReplyTo, ccAddresses, bccAddresses, htmlBody, textBody, folderId, labels, headers
Tweet@happyvertical/smrt-messages:TweettweetId, retweetCount, likeCount, mediaUrls, hashtags, mentions
SlackMessage@happyvertical/smrt-messages:SlackMessagechannelId, slackTs, slackThreadTs, reactions, blocks

Accounts (share accounts table)

TypeSTI discriminatorNotes
Account(base)providerType, credentialSecretId, isActive, lastSyncAt, settings
EmailAccount@happyvertical/smrt-messages:EmailAccountSMTP / IMAP provider-specific fields + sync methods
SlackAccount@happyvertical/smrt-messages:SlackAccountSlack workspace connection
TwitterAccount@happyvertical/smrt-messages:TwitterAccountTwitter 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.

typescript
// 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:

SenderDescription
EmailSenderSend emails via the @happyvertical/email client
SlackSenderPost Slack messages via API
TweetSenderPost 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.

typescript
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.

typescript
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)

typescript
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
typescript
import {
  EmailAccountManager,
  EmailFilterManager,
  MessageCard,
  MessageList,
} from '@happyvertical/smrt-messages/svelte';

Collections

typescript
// Base collections (query across all channels)
MessageCollection
AccountCollection
AttachmentCollection

// Email-specific collections
EmailCollection
EmailAccountCollection
EmailAttachmentCollection
EmailFolderCollection

// Email filtering
WhitelistCollection
BlacklistCollection

Best Practices

DOs

  • Always use setCredentials() / getCredentials() for account credentials
  • Use JSON address helpers: getToAddresses(), getCcAddresses()
  • Handle send failures with retrySend() (respects maxRetries)
  • Use MessageCollection to 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() — use transformJSON()
  • Don't use the deprecated emailId on Attachment in new code
  • Don't run concurrent syncs on the same account

Related Modules