@happyvertical/smrt-messages
Email persistence with AI integration, templating, and delivery tracking.
Overview
smrt-messages provides TypeScript-first abstractions for managing email across multiple providers. It persists email data with rich metadata (threading, attachments, flags, labels) and synchronizes emails from remote servers with configurable options.
Installation
npm install @happyvertical/smrt-messages
# or
pnpm add @happyvertical/smrt-messagesDepends on @happyvertical/smrt-core, @happyvertical/email,
and @happyvertical/sql.
Quick Start (5 Minutes)
1. Set Up Email Account
import { EmailAccount, EmailAccountCollection } from '@happyvertical/smrt-messages';
const accounts = new EmailAccountCollection({ db: {...} });
// Create Gmail account
const account = new EmailAccount({
name: 'Personal Gmail',
email: 'user@gmail.com',
providerType: 'gmail'
});
account.setSettings({
host: 'imap.gmail.com',
port: 993,
secure: true,
auth: {
user: 'user@gmail.com',
pass: 'app-specific-password' // NOT your Gmail password
}
});
await account.save();2. Sync Emails
// Perform initial sync
const result = await account.syncFrom({
folders: ['INBOX', 'SENT'],
fullSync: true,
downloadAttachments: true,
maxConcurrency: 5,
onProgress: (stats) => {
console.log(`Synced ${stats.processed}/${stats.total}`);
}
});
console.log(`Downloaded: ${result.messagesDownloaded}`);
console.log(`Duration: ${result.duration}ms`);3. Query Emails
import { EmailCollection } from '@happyvertical/smrt-messages';
const emails = new EmailCollection({ db: {...} });
// Get unread emails
const unread = await emails.getUnread(account.id);
// Get recent emails
const recent = await emails.getRecent(20, account.id);
// Get emails with attachments
const withAttachments = await emails.getWithAttachments(account.id);
// Get thread conversation
const email = await emails.get(emailId);
const threadEmails = await email.getThreadEmails();Core Concepts
Data Model
Four core entities form the email management system:
Email Model
class Email extends SmrtObject {
accountId: string // Parent account
folderId: string // Current folder
messageId: string // RFC 822 Message-ID (unique)
threadId: string | null // Conversation grouping
subject: string
fromAddress: string
fromName: string
toAddresses: string // JSON array
ccAddresses: string // JSON array
bccAddresses: string // JSON array
replyTo: string | null
date: Date | null
textBody: string
htmlBody: string
isRead: boolean
isFlagged: boolean
isAnswered: boolean
isDraft: boolean
hasAttachments: boolean
size: number // Bytes
labels: string // JSON array (Gmail)
flags: string // JSON array (IMAP)
headers: string // JSON object
inReplyTo: string | null // Parent message-ID
references: string | null // Thread references
rawMessage: string | null // Full MIME message
}EmailAccount Model
class EmailAccount extends SmrtObject {
name: string
email: string
providerType: 'smtp' | 'imap' | 'pop3' | 'gmail'
settings: string // JSON (encrypt in production)
lastSyncAt: Date | null
active: boolean
// Methods
async syncFrom(options): Promise<SyncResult>
async createClient(): Promise<EmailClient>
async getFolders(): Promise<EmailFolder[]>
async getEmails(limit?): Promise<Email[]>
async getUnreadCount(): Promise<number>
}EmailFolder Model
class EmailFolder extends SmrtObject {
accountId: string
name: string
path: string
delimiter: string
specialUse: string | null // 'Inbox', 'Sent', 'Drafts', etc.
messageCount: number
unreadCount: number
subscribed: boolean
// Helper methods
isInbox(): boolean
isSent(): boolean
isDrafts(): boolean
isSpam(): boolean
isSystemFolder(): boolean
}EmailAttachment Model
class EmailAttachment extends SmrtObject {
emailId: string
filename: string
contentType: string
size: number
contentDisposition: string // 'attachment' | 'inline'
contentId: string | null // For inline images
filePath: string | null // External storage path
// Helper methods
isImage(): boolean
isPdf(): boolean
isInline(): boolean
getExtension(): string
getFormattedSize(): string
async readContent(): Promise<Buffer | null>
}Message Threading
Emails are grouped into conversations using threadId:
// Get all emails in a thread
const email = await emails.get(emailId);
const thread = await email.getThreadEmails();
// Sort chronologically
const sorted = thread.sort((a, b) => {
return (a.date?.getTime() || 0) - (b.date?.getTime() || 0);
});
console.log(`Thread has ${sorted.length} messages`);Sync Engine
The syncFrom() method synchronizes emails from remote servers:
const result = await account.syncFrom({
folders: ['INBOX'], // Specific folders or omit for all
fullSync: false, // true = re-download everything
downloadAttachments: true, // Save attachment files
maxAttachmentSize: 10485760, // 10MB limit
batchSize: 100, // Messages per batch
maxConcurrency: 5, // Parallel connections
since: new Date('2024-01-01'), // Incremental sync
onProgress: (stats) => {
// stats: { total, processed, current, folder }
},
onError: (error, message) => {
// Handle sync errors
}
});
// Result contains:
// - messagesDownloaded: number
// - attachmentsDownloaded: number
// - errors: Array<{ error, message }>
// - duration: number (ms)API Reference
EmailCollection Methods
// Queries
await emails.getByMessageId(accountId, messageId)
await emails.getByAccount(accountId)
await emails.getByFolder(folderId)
await emails.getByThread(threadId)
await emails.getUnread(accountId?)
await emails.getFlagged(accountId?)
await emails.getWithAttachments(accountId?)
await emails.getRecent(limit, accountId?)
// Counts
await emails.countByFolder(folderId)
await emails.countUnreadByFolder(folderId)
await emails.countByAccount(accountId)
// CRUD
await emails.list(options)
await emails.get(id)
await emails.create(data)
await emails.update(id, data)
await emails.delete(id)Email Instance Methods
// Status management
await email.markRead()
await email.markUnread()
await email.toggleFlagged()
// Relationships
await email.getAccount()
await email.getFolder()
await email.getAttachments()
await email.getThreadEmails()
// Address parsing
email.getToAddresses(): Array<{ name: string, address: string }>
email.getCcAddresses(): Array<{ name: string, address: string }>
email.getBccAddresses(): Array<{ name: string, address: string }>
// Metadata
email.getLabels(): string[]
email.setLabels(labels: string[]): void
email.getFlags(): string[]
email.setFlags(flags: string[]): void
email.getHeaders(): Record<string, string>
email.setHeaders(headers: Record): void
// Utilities
email.isUnread(): boolean
email.getPreview(maxLength: number): stringEmailAccountCollection Methods
await accounts.getByEmail(email: string)
await accounts.getByProviderType(type: string)
await accounts.getActive()
await accounts.getInactive()
await accounts.getNeedingSync(maxAgeMinutes: number)
await accounts.search(query: string, filters?)Real-World Examples
Example 1: Email Dashboard Widget
// Show unread counts per folder
const account = await accounts.get(accountId);
const folders = await account.getFolders();
for (const folder of folders) {
if (folder.isSystemFolder()) {
console.log(`${folder.name}: ${folder.unreadCount} unread`);
}
}Example 2: Conversation Thread UI
const email = await emails.get(emailId);
const thread = await email.getThreadEmails();
const sorted = thread.sort((a, b) => {
return (a.date?.getTime() || 0) - (b.date?.getTime() || 0);
});
const conversation = await Promise.all(
sorted.map(async (e) => ({
from: e.fromName || e.fromAddress,
subject: e.subject,
date: e.date,
preview: e.getPreview(100),
isRead: e.isRead,
attachments: await e.getAttachments()
}))
);
console.log(`Thread with ${conversation.length} messages`);Example 3: Attachment Processing
// Process all PDF attachments
const withAttachments = await emails.getWithAttachments(accountId);
for (const email of withAttachments) {
const attachments = await email.getAttachments();
const pdfs = attachments.filter(a => a.isPdf());
for (const pdf of pdfs) {
console.log(`Processing: ${pdf.filename} (${pdf.getFormattedSize()})`);
const content = await pdf.readContent();
if (content) {
// Process PDF content
await processPdfData(content);
}
}
}Example 4: Automated Email Archive
// Archive emails older than 6 months
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const all = await emails.list({ where: { accountId } });
const older = all.filter(e => e.date && e.date < sixMonthsAgo);
for (const email of older) {
// Mark as archived
email.flags = JSON.stringify(['\\Archive']);
await email.save();
console.log(`Archived: ${email.subject}`);
}Integration Patterns
With @happyvertical/email
EmailAccount creates provider clients for sync:
const client = await account.createClient();
const folders = await client.listFolders();
const messages = await client.fetch({ folder: 'INBOX' });With @happyvertical/ai
Email model inherits SmrtObject with AI capabilities:
// AI-powered email summarization
const summary = await email.do('summarize this email thread');
// Sentiment analysis
const sentiment = await email.is('positive or negative');With smrt-users
Link email accounts to user profiles:
// User can have multiple email accounts
const userAccounts = await accounts.list({
where: { userId: user.id, active: true }
});
// Permission-based access
if (user.can('view:emails')) {
const emails = await emails.getByAccount(accountId);
}With smrt-assets
Store attachments as assets:
// Save attachment to asset system
const attachment = await attachments.get(attachmentId);
const content = await attachment.readContent();
const asset = await assets.create({
filename: attachment.filename,
contentType: attachment.contentType,
size: attachment.size,
content: content
});Best Practices
DOs
- Use app-specific passwords for Gmail/Outlook (not main password)
- Encrypt settings JSON before storing in database
- Implement incremental sync with
sinceoption - Handle sync errors gracefully with
onErrorcallback - Set reasonable batch sizes (100-500) to balance memory/speed
- Cache folder lists in UI (they change infrequently)
- Use thread IDs for conversation grouping
- Parse address arrays with
getToAddresses()helpers
DON'Ts
- Don't store passwords in plaintext (always encrypt)
- Don't assume all providers support same features
- Don't download all attachments unconditionally
- Don't skip threadId during sync
- Don't modify JSON fields directly (use accessor methods)
- Don't run concurrent syncs on same account
- Don't ignore
onErrorcallbacks - Don't bulk-delete without backup
Common Issues and Troubleshooting
Issue: "Message already exists" after sync
Cause: Incremental sync skips existing messages
Solution: Use fullSync: true for complete re-sync
Issue: Attachment reads return null
Cause: File not found or file service not configured
Solution: Check attachment.filePath exists and file service is set up
Issue: ThreadId is empty after sync
Cause: Email provider didn't return threadId
Solution: Reconstruct threads using inReplyTo and message-ID headers
Issue: Sync takes too long
Cause: Large mailbox (10k+ emails) on first sync
Solution: Use smaller batchSize, higher maxConcurrency,
or sync only recent emails with since
await account.syncFrom({
batchSize: 50,
maxConcurrency: 10,
since: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000) // 90 days
});Issue: Unread count stale after marking read
Cause: Folder counts not updated after email operations
Solution: Call folder.refreshCounts() after bulk operations
await email.markRead();
const folder = await email.getFolder();
await folder?.refreshCounts();