Email System
Owlat's email system consists of a visual editor, template management, and multi-provider sending infrastructure.
Owlat's email system consists of a visual editor, template management, and multi-provider sending infrastructure.
Architecture Overview
┌──────────────────────────────────────────────────────────────┐
│ Email Builder │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Inline Editor │ │
│ │ - Slash commands (/text, /image, /button, etc.) │ │
│ │ - Floating format bar for text styling │ │
│ │ - Media library integration for images │ │
│ │ - Focus mode (Cmd+Shift+F) │ │
│ │ - Auto-save every 30 seconds │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Block System │ │
│ │ - Text (paragraph, h1, h2, h3), Image, Button │ │
│ │ - Divider, Spacer, Columns, Social, Container │ │
│ │ - Variables, Saved blocks (/block command) │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────┬─────────────────────────────────┘
│ JSON Blocks
▼
┌──────────────────────────────────────────────────────────────┐
│ Email Rendering │
│ - @owlat/email-renderer converts JSON blocks to HTML │
│ - Responsive output with cross-client compatibility │
│ - Custom rendering pipeline (no MJML dependency) │
└────────────────────────────┬─────────────────────────────────┘
│ HTML
▼
┌──────────────────────────────────────────────────────────────┐
│ Email Providers │
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
│ │ SES Provider │ │ Resend Provider │ │
│ │ (default) │ │ (optional) │ │
│ └──────────────────────┘ └─────────────────────────────┘ │
│ │
│ Features: │
│ - Retry logic with exponential backoff │
│ - Workpool rate limiting (30/sec transactional, 20/sec campaign) │
│ - Domain verification enforcement │
│ - Consistent error format │
└──────────────────────────────────────────────────────────────┘
Email Builder Package
The email builder is in packages/email-builder/ and provides:
Components
| Component | Purpose |
|---|---|
EmailBuilder.vue | Main editor component |
DocumentCanvas.vue | Block rendering area (single-column canvas) |
SubjectFields.vue | Inline subject/name fields at top of canvas |
UnifiedToolbar.vue | Contextual block toolbar |
FloatingBlockSidebar.vue | Block actions sidebar (move, delete, etc.) |
PreviewPanel.vue | Live HTML preview |
Usage
<template>
<EmailBuilder
v-model:blocks="blocks"
v-model:subject="subject"
:config="config"
:saved-blocks="savedBlocks"
:is-saving="isSaving"
@save="handleSave"
@settings="handleSettings"
/>
</template>
<script setup>
const blocks = ref([]);
const subject = ref('');
const isSaving = ref(false);
const config = {
hideSubject: true, // Hide subject field (default: true)
mode: 'email', // 'email' | 'block'
};
</script>
Config Options
| Option | Type | Default | Description |
|---|---|---|---|
variableType | string | - | Variable rendering mode ('personalization' or 'data') |
blockTypes | BlockType | - | Restrict available block types in sidebar (default: all) |
theme | EmailTheme | - | Email theme for styling (colors, fonts, spacing) |
showMandatoryUnsubscribeFooter | boolean | false | Show a required, non-editable unsubscribe footer |
hideSubject | boolean | true | Hide subject input field |
mode | string | 'email' | 'email' for templates, 'block' for saved blocks |
Block System
Block Structure
Blocks are defined in @owlat/shared:
type BlockType =
| 'text'
| 'image'
| 'button'
| 'divider'
| 'spacer'
| 'columns'
| 'social'
| 'container'
| 'hero'
| 'table'
| 'rawHtml'
| 'video'
| 'accordion'
| 'menu'
| 'carousel'
| 'list'
| 'progressBar'
| 'countdown';
interface EditorBlock {
id: string;
type: BlockType;
content: BlockContent; // Discriminated union per type
}
Each block type has a specific content interface. For example:
// Text blocks handle both paragraphs and headings
interface TextBlockContent {
html: string;
blockType: 'paragraph' | 'h1' | 'h2' | 'h3';
fontSize: number;
textColor: string;
textAlign?: 'left' | 'center' | 'right';
// ... padding, margin, border, backgroundColor
}
// Containers can nest other blocks recursively
interface ContainerBlockContent {
items: ContainerItem[];
maxWidth: number;
backgroundColor?: string;
borderRadius: number;
// ... padding, margin, border
}
Available Block Types
| Type | Slash Command | Description |
|---|---|---|
text | /text, /h1-/h3 | Rich text with paragraph or heading variants |
image | /image | Image with URL, alt text, link, retina/dark swap |
button | /button | CTA button with VML bulletproof Outlook rendering |
divider | /divider | Horizontal line separator |
spacer | /spacer | Vertical spacing |
columns | /columns | 1-4 column layout with ratio presets and gap |
social | /social | Social media icon links (17 platforms) |
container | /container | Group blocks with shared styling |
hero | /hero | Full-width background image section with VML |
table | /table | Data table with rich cells and responsive modes |
rawHtml | /rawHtml | Raw HTML injection (advanced escape hatch) |
video | /video | Video thumbnail with play button overlay |
accordion | /accordion | CSS-only expandable sections |
menu | /menu | Horizontal navigation with mobile hamburger |
carousel | /carousel | CSS-only image slideshow |
list | /list | Styled list with custom markers (table-based) |
progressBar | /progressBar | Visual progress indicator |
countdown | /countdown | Countdown timer with optional live image |
Headings are not a separate block type. They are text blocks with blockType set to h1, h2, or h3.
Renderer Features
The @owlat/email-renderer package provides more than block-to-HTML conversion. Key capabilities include:
- CSS inlining — styles applied inline for Gmail/Yahoo compatibility (enabled by default)
- Dark mode —
@media (prefers-color-scheme: dark)rules, image swapping, per-block overrides - Outlook VML — bulletproof buttons, background images, and fixed-width tables
- Plain text — multipart plain text output for accessibility and deliverability
- AMP for Email — third format for interactive components in Gmail/Yahoo
- Conditional content — show/hide blocks based on variable values
- Repeat blocks — iterate over array variables for product lists, order items, etc.
- Block validation — pre-render structural checks with accessibility audit
- Email analysis — post-render size analysis, Gmail clipping detection, image/link counts
- Template diff — structural comparison between email versions
- Link transforms — UTM/click-tracking URL rewriting
- Custom block registry — register third-party block renderers
- Gradient backgrounds — CSS linear-gradient with VML fallback on buttons, containers, and hero blocks
- CSS animations —
fadeInandslideUpwithprefers-reduced-motionsupport
See the Email Renderer docs for full details.
Saved Blocks
Users can save reusable content:
interface SavedBlock {
_id: string;
name: string;
description?: string;
content: string; // JSON string of blocks
usageCount: number;
blockCount?: number;
}
Insert with /block command - shows picker of saved blocks.
Multi-language Support
Templates support translations:
interface EmailTemplate {
// Default language content
subject: string
content: string // JSON blocks
defaultLanguage: string // e.g., 'en'
// Translations
supportedLanguages: string[] // ['en', 'de', 'fr']
translations: string // JSON of translations
// Pre-rendered HTML per language
htmlContent: string // Default language
htmlTranslations: string // JSON: { "de": { htmlContent, subject }, ... }
}
// Translations structure
{
"de": {
"subject": "German subject",
"previewText": "German preview",
"blocks": { /* text content overrides */ }
}
}
Important: Styling (colors, padding, images) is shared across all languages. Only text content varies per language.
Email Provider Abstraction
Interface
// lib/emailProviders/types.ts
interface EmailProvider {
sendEmail(params: EmailSendParams): Promise<EmailSendResult>;
sendBatch(emails: EmailBatchParams): Promise<EmailBatchResult[]>;
getProviderName(): string;
}
interface EmailSendParams {
to: string;
from: string;
subject: string;
html: string;
replyTo?: string;
headers?: Record<string, string>;
attachments?: EmailAttachment[];
}
interface EmailAttachment {
filename: string;
content?: string; // Base64-encoded
url?: string; // URL to fetch from
contentType?: string;
}
interface EmailSendResult {
success: boolean;
id?: string;
error?: string;
}
Provider Factory
// lib/emailProviders/index.ts
export function getEmailProvider(): EmailProvider {
const provider = process.env.EMAIL_PROVIDER || 'ses';
switch (provider) {
case 'resend':
return new ResendProvider();
case 'ses':
default:
return new SESProvider();
}
}
Resend Provider
// lib/emailProviders/resend.ts
class ResendProvider implements EmailProvider {
private client: Resend;
private retryDelays = [1000, 5000, 30000];
async sendEmail(params: EmailSendParams): Promise<EmailSendResult> {
for (let attempt = 0; attempt <= this.retryDelays.length; attempt++) {
try {
const result = await this.client.emails.send({
from: params.from,
to: params.to,
subject: params.subject,
html: params.html,
replyTo: params.replyTo,
headers: params.headers,
});
return { success: true, id: result.data?.id };
} catch (error) {
if (!isRetryableError(error) || attempt === this.retryDelays.length) {
return { success: false, error: error.message };
}
await sleep(this.retryDelays[attempt]);
}
}
}
}
SES Provider
// lib/emailProviders/ses.ts
class SESProvider implements EmailProvider {
private client: SESClient;
constructor() {
this.client = new SESClient({
region: process.env.AWS_SES_REGION,
credentials: {
accessKeyId: process.env.AWS_SES_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SES_SECRET_ACCESS_KEY!,
},
});
}
async sendEmail(params: EmailSendParams): Promise<EmailSendResult> {
const command = new SendEmailCommand({
Source: params.from,
Destination: { ToAddresses: [params.to] },
Message: {
Subject: { Data: params.subject },
Body: { Html: { Data: params.html } },
},
ReplyToAddresses: params.replyTo ? [params.replyTo] : undefined,
});
const result = await this.client.send(command);
return { success: true, id: result.MessageId };
}
}
Domain Verification
Before sending, domains must be registered with AWS SES and DNS records verified.
SES Identity Management
Domain registration is handled by lib/emailProviders/sesIdentity.ts, which wraps the AWS SES identity APIs:
| Method | SES Commands | Purpose |
|---|---|---|
registerDomain() | VerifyDomainIdentity + VerifyDomainDkim | Creates identity, returns verification token + 3 DKIM tokens |
setupMailFromDomain() | SetIdentityMailFromDomain | Configures custom MAIL FROM subdomain |
getVerificationStatus() | GetIdentityVerificationAttributes + GetIdentityDkimAttributes | Checks SES-side verification |
deleteIdentity() | DeleteIdentity | Removes identity from SES |
DNS Records Generated
| Record | Type | Host | Value |
|---|---|---|---|
| SPF | TXT | @ | v=spf1 include:amazonses.com ~all |
| DKIM (x3) | CNAME | {token}._domainkey | {token}.dkim.amazonses.com |
| DMARC | TXT | _dmarc | v=DMARC1; p=none; rua=mailto:dmarc@{domain} |
| MAIL FROM MX | MX | mail | 10 feedback-smtp.{region}.amazonses.com |
| MAIL FROM SPF | TXT | mail | v=spf1 include:amazonses.com ~all |
Domain Status Flow
User adds domain → status: "registering"
│
▼ (scheduled action: sesActions.registerDomainWithSES)
SES registration succeeds → status: "pending" (DNS records populated)
SES registration fails → status: "failed" (sesRegistrationError set)
│
▼ (user clicks Verify)
DNS + SES checks pass → status: "verified"
DNS or SES checks fail → status: "failed"
Key Files
| File | Purpose |
|---|---|
sesActions.ts | Internal actions: registerDomainWithSES, deleteDomainFromSES, checkSESVerificationStatus, migrateExistingDomains |
lib/emailProviders/sesIdentity.ts | SES identity management service (pure library) |
dnsVerification.ts | DNS record verification action (SPF, DKIM array, DMARC, MAIL FROM MX/SPF, SES status) |
dnsVerificationQueries.ts | Internal queries/mutations for domain data and SES registration updates |
domains.ts | Domain CRUD mutations, type definitions (DnsRecords, VerificationResults) |
Verification Flow
- User adds domain in Settings > Domains
- Domain inserted with
registeringstatus, SES registration scheduled - Background action registers with SES, sets up MAIL FROM, builds DNS records
- Status transitions to
pendingwith real SES-generated DKIM tokens - User adds DNS records with their provider
- User clicks "Verify" — system checks all DNS records + SES verification status
- Domain marked
verifiedonly when all DNS records pass AND SES reportsSuccess
Enforcement
// lib/emailProviders/domainVerification.ts
// Before sending any email
const result = await validateDomainForSending(db, organizationId, fromEmail);
// Throws if domain is 'registering' or not verified
// Returns warning if verification is stale (>24 hours)
Verification Freshness
Verification expires after 24 hours and is re-checked before sending.
Sending Rate Control
Email sending uses a workpool-based rate limiting system to stay within provider limits:
- Transactional emails are sent at higher priority with minimal throttling for time-sensitive delivery (password resets, order confirmations).
- Campaign emails are sent in bulk batches with workpool-based rate limiting (20/sec) to avoid hitting provider rate limits.
- Provider-level limits apply on top of application throttling — SES and Resend each enforce their own sending quotas. Monitor your provider dashboard for current limits.
The rate limiter is built into the email provider abstraction, so all sends (campaigns, automations, transactional) go through the same throttling layer.
Campaign Sending
Flow
- Campaign Created - Template selected, audience chosen
- Domain Verified - Check sender domain is verified
- Audience Resolved - Get eligible contacts (double opt-in)
- HTML Rendered - Per-language rendering
- Batch Processing - Send in batches with rate limiting
- Status Tracking - Track sent, delivered, opened, clicked
Code
// campaigns.ts
export const startCampaignSend = action({
args: { campaignId: v.id('campaigns') },
handler: async (ctx, args) => {
const campaign = await ctx.runQuery(api.campaigns.get, args);
// Verify domain
const domainStatus = await ctx.runQuery(api.domains.getEmailDomainVerificationStatus, {
teamId: campaign.teamId,
email: campaign.fromEmail,
});
if (!domainStatus.isVerified) {
throw new Error(`Domain not verified: ${domainStatus.domain}`);
}
// Get eligible contacts
const contacts = await ctx.runQuery(api.campaigns.getEligibleContacts, {
campaignId: args.campaignId,
});
// Send in batches
const provider = getEmailProvider();
for (const batch of chunk(contacts, 100)) {
await provider.sendBatch(
batch.map((contact) => ({
to: contact.email,
from: campaign.fromEmail,
subject: campaign.subject,
html: getLocalizedHtml(campaign, contact.language),
}))
);
}
},
});
Transactional Emails
API-triggered emails with variables:
Sending via API
curl -X POST https://your-deployment.convex.site/api/v1/transactional \
-H "Authorization: Bearer lm_live_..." \
-H "Content-Type: application/json" \
-d '{
"slug": "order-confirmation",
"email": "customer@example.com",
"dataVariables": {
"orderNumber": "12345",
"total": "$99.00"
},
"language": "en"
}'
Or use the TypeScript SDK:
import { Owlat } from '@owlat/sdk-js'
const owlat = new Owlat('lm_live_...')
await owlat.transactional.send({
slug: 'order-confirmation',
email: 'customer@example.com',
dataVariables: {
orderNumber: '12345',
total: '$99.00',
},
language: 'en',
})
Variable System
Variables are inserted via the inline text editor as inline nodes. There are two variable types:
- Personalization variables - Contact fields like
firstName,lastName,email - Data variables - Custom values passed via the API
dataVariablesobject
Variables are replaced at send time during HTML rendering.