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

ComponentPurpose
EmailBuilder.vueMain editor component
DocumentCanvas.vueBlock rendering area (single-column canvas)
SubjectFields.vueInline subject/name fields at top of canvas
UnifiedToolbar.vueContextual block toolbar
FloatingBlockSidebar.vueBlock actions sidebar (move, delete, etc.)
PreviewPanel.vueLive 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

OptionTypeDefaultDescription
variableTypestring-Variable rendering mode ('personalization' or 'data')
blockTypesBlockType-Restrict available block types in sidebar (default: all)
themeEmailTheme-Email theme for styling (colors, fonts, spacing)
showMandatoryUnsubscribeFooterbooleanfalseShow a required, non-editable unsubscribe footer
hideSubjectbooleantrueHide subject input field
modestring'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

TypeSlash CommandDescription
text/text, /h1-/h3Rich text with paragraph or heading variants
image/imageImage with URL, alt text, link, retina/dark swap
button/buttonCTA button with VML bulletproof Outlook rendering
divider/dividerHorizontal line separator
spacer/spacerVertical spacing
columns/columns1-4 column layout with ratio presets and gap
social/socialSocial media icon links (17 platforms)
container/containerGroup blocks with shared styling
hero/heroFull-width background image section with VML
table/tableData table with rich cells and responsive modes
rawHtml/rawHtmlRaw HTML injection (advanced escape hatch)
video/videoVideo thumbnail with play button overlay
accordion/accordionCSS-only expandable sections
menu/menuHorizontal navigation with mobile hamburger
carousel/carouselCSS-only image slideshow
list/listStyled list with custom markers (table-based)
progressBar/progressBarVisual progress indicator
countdown/countdownCountdown 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 animationsfadeIn and slideUp with prefers-reduced-motion support

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:

MethodSES CommandsPurpose
registerDomain()VerifyDomainIdentity + VerifyDomainDkimCreates identity, returns verification token + 3 DKIM tokens
setupMailFromDomain()SetIdentityMailFromDomainConfigures custom MAIL FROM subdomain
getVerificationStatus()GetIdentityVerificationAttributes + GetIdentityDkimAttributesChecks SES-side verification
deleteIdentity()DeleteIdentityRemoves identity from SES

DNS Records Generated

RecordTypeHostValue
SPFTXT@v=spf1 include:amazonses.com ~all
DKIM (x3)CNAME{token}._domainkey{token}.dkim.amazonses.com
DMARCTXT_dmarcv=DMARC1; p=none; rua=mailto:dmarc@{domain}
MAIL FROM MXMXmail10 feedback-smtp.{region}.amazonses.com
MAIL FROM SPFTXTmailv=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

FilePurpose
sesActions.tsInternal actions: registerDomainWithSES, deleteDomainFromSES, checkSESVerificationStatus, migrateExistingDomains
lib/emailProviders/sesIdentity.tsSES identity management service (pure library)
dnsVerification.tsDNS record verification action (SPF, DKIM array, DMARC, MAIL FROM MX/SPF, SES status)
dnsVerificationQueries.tsInternal queries/mutations for domain data and SES registration updates
domains.tsDomain CRUD mutations, type definitions (DnsRecords, VerificationResults)

Verification Flow

  1. User adds domain in Settings > Domains
  2. Domain inserted with registering status, SES registration scheduled
  3. Background action registers with SES, sets up MAIL FROM, builds DNS records
  4. Status transitions to pending with real SES-generated DKIM tokens
  5. User adds DNS records with their provider
  6. User clicks "Verify" — system checks all DNS records + SES verification status
  7. Domain marked verified only when all DNS records pass AND SES reports Success

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

  1. Campaign Created - Template selected, audience chosen
  2. Domain Verified - Check sender domain is verified
  3. Audience Resolved - Get eligible contacts (double opt-in)
  4. HTML Rendered - Per-language rendering
  5. Batch Processing - Send in batches with rate limiting
  6. 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 dataVariables object

Variables are replaced at send time during HTML rendering.