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 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';
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 |
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 || 'mta';
switch (provider) {
case 'mta':
return createMtaProvider();
case 'resend':
return new ResendProvider();
case 'ses':
return new SESProvider();
default:
throw new Error(`Unknown email provider: ${provider}`);
}
}
Owlat MTA is the default email provider. Set EMAIL_PROVIDER=resend or EMAIL_PROVIDER=ses to use an alternative provider instead.
MTA Provider
The custom MTA provider sends emails via direct SMTP delivery through the Owlat MTA service. Instead of calling a third-party API, the Convex backend POSTs email jobs to the MTA's HTTP API, which handles queuing, rate limiting, DKIM signing, and MX delivery.
// lib/emailProviders/mta.ts
class MtaProvider implements EmailProvider {
private baseUrl: string;
private apiKey: string;
async sendEmail(params: EmailSendParams): Promise<EmailSendResult> {
const response = await fetch(`${this.baseUrl}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
messageId: crypto.randomUUID(),
to: params.to,
from: params.from,
subject: params.subject,
html: params.html,
ipPool: 'transactional', // or 'campaign'
dkimDomain: extractDomain(params.from),
}),
});
const result = await response.json();
return { success: result.success, id: result.id };
}
}
The MTA provider includes retry logic for transient errors (429, 5xx) and a 30-second request timeout. Batch sends are processed sequentially since the MTA handles internal queuing.
See the MTA System page for details on the intelligence pipeline, bounce processing, IP warming, and configuration.
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 };
}
}
Email Security Scanning
All outbound email passes through multi-layered security scanning before delivery. The @owlat/email-scanner package provides all scanning logic as a shared library, consumed by both apps/api (Convex) and apps/mta.
See the Email Security page for full details.
Content Scanning
Analyzes email subject and HTML body for spam, phishing, and prohibited content:
- Spam keywords — 40+ weighted patterns (e.g., "free money", "act now", "Nigerian prince")
- Phishing URLs — URL shortener detection, anchor/href domain mismatch
- Homoglyph spoofing — Unicode confusable characters (~50 mappings, e.g., Cyrillic
аvs Latina) - Prohibited content — Advance fee fraud, credential phishing patterns
- Subject analysis — ALL CAPS abuse, excessive punctuation
Results produce a score (0–100) with severity weights: high (20 pts), medium (10 pts), low (3 pts). Thresholds: clean < 15, suspicious 15–39, blocked ≥ 40.
File Validation
Validates attachments and media uploads before storage or sending:
- Magic bytes — Identifies real file type from binary headers (first 16 bytes)
- Double extension — Detects attacks like
invoice.pdf.exe - Extension allowlist — Permits images, PDFs, documents, spreadsheets, archives
- MIME type allowlist — Validates declared content types
Applied in emailWorker.ts (attachments) and mediaAssets.ts (media uploads).
URL Reputation
Checks URLs against the Google Safe Browsing API v4:
- Batch checks up to 500 URLs per request
- Threat types: MALWARE, SOCIAL_ENGINEERING, UNWANTED_SOFTWARE
- Campaign sends: blocking gate — flagged URLs prevent sending
- Transactional sends: graceful — flags for review without blocking
- Results cached in
urlReputationCachetable (24h clean, 1h flagged) - Requires
GOOGLE_SAFE_BROWSING_API_KEYenvironment variable
ClamAV Malware Scanning
Scans attachment binary data for known malware signatures:
- ClamAV runs as a Docker sidecar alongside the MTA
- MTA exposes
POST /scan/attachmentendpoint - Convex
emailWorkercalls this endpoint for each attachment before sending - Fail-open design: ClamAV unavailability does not block email delivery
Feedback Loops
Spam complaints from ISP feedback loops are linked back to campaign content scan results, enabling pattern learning and complaint rate tracking per campaign.
Key Files
| File | Purpose |
|---|---|
packages/email-scanner/src/content/ | Content analysis (spam, phishing, homoglyphs) |
packages/email-scanner/src/files/ | File type validation (magic bytes, extensions) |
packages/email-scanner/src/urls/ | URL reputation (Safe Browsing API) |
packages/email-scanner/src/clamav/ | ClamAV TCP client (Node.js only, used by MTA) |
apps/api/convex/lib/contentScanner.ts | Thin re-export wrapper from @owlat/email-scanner |
apps/api/convex/emailWorker.ts | Attachment validation + ClamAV scan integration |
apps/api/convex/mediaAssets.ts | Upload file validation |
apps/mta/src/routes/scan.ts | MTA scan endpoint |
Domain Verification
Before sending, domains must be verified with proper DNS records (SPF, DKIM, DMARC). The MTA handles DKIM signing directly; when using SES as a provider, domains are additionally registered through the SES identity API.
SES Identity Management
When using the SES provider, 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
registeringpendingfailedverifiedfailedKey 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 — the MTA handles rate limiting internally (per-ISP throttling, IP warming caps); SES and Resend each enforce their own sending quotas.
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
- Content Scanned - Spam, phishing, homoglyph, and prohibited content analysis
- URL Reputation Checked - Google Safe Browsing API (if configured)
- Audience Resolved - Get eligible contacts (double opt-in)
- HTML Rendered - Per-language rendering
- Attachments Validated - File type validation + ClamAV malware scan (if attachments present)
- 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.