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

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"
        v-model:name="name"
        v-model:background-color="backgroundColor"
        :variables="variables"
        :config="config"
        :is-saving="isSaving"
        @save="handleSave"
        @settings="handleSettings"
    />
</template>

<script setup>
import { provideEmailBuilderHandlers } from '@owlat/email-builder';

const blocks = ref([]);
const subject = ref('');
const name = ref('');
const backgroundColor = ref('#ffffff');
const variables = ref([]);
const isSaving = ref(false);

const config = {
    hideSubject: true, // Hide subject field (default: true)
    mode: 'email', // 'email' | 'block'
};

// Backend integration (image upload, saved blocks, media library) is wired
// through a provide/inject contract, NOT props. Call this in a parent
// component before EmailBuilder mounts.
provideEmailBuilderHandlers({
    uploadImage: async (file) => ({ url: await upload(file) }),
    savedBlocks: {
        fetch: (params) => fetchSavedBlocks(params),
        save: (block) => saveBlock(block),
    },
});
</script>

The required props are blocks, subject, name, and variables; backgroundColor, config, and isSaving are optional (EmailBuilder.vue:70-78). There is no saved-blocks prop — saved blocks, image upload, and the media-library picker are supplied through the EmailBuilderHandlers provide/inject contract (packages/email-builder/src/types/editor.ts:44), resolved via useEmailBuilderHandlers() inside the component.

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';

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

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;
}

Saved blocks appear in the slash menu (type /) under their own names; selecting one inserts it.

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

Send-side provider work lives in apps/api/convex/lib/sendProviders/ and follows ADR-0020. The layer has four moving parts:

  1. Adapters — one object-literal module per provider (mta, ses, resend), each doing a single-attempt send and classifying its own errors.
  2. RegistrySEND_PROVIDERS maps a SendProviderKind to its adapter; providerFor(kind) looks one up.
  3. RoutingresolveRoute() reads an org's providerRoutes config, picks a strategy, and returns the provider to use.
  4. DispatchsendProviderDispatch() owns the retry loop and writes per-provider health after every terminal outcome.

Adapter Interface

Each provider implements SendProviderModule<K> from lib/sendProviders/types.ts. There is no sendBatch and no getProviderName — the module is a single-attempt send plus error categorization, and the dispatch helper drives the rest.

// lib/sendProviders/types.ts
type SendProviderKind = 'mta' | 'ses' | 'resend';

interface SendProviderModule<K extends SendProviderKind> {
    readonly kind: K;
    /** Backoff schedule consumed by the dispatch helper (the module never retries). */
    readonly retryDelays: readonly number[];
    /** One attempt. No internal retry. */
    sendEmail(
        params: EmailSendParams,
        extras?: ExtrasFor<K>,
    ): Promise<EmailSendAttempt>;
    /** Map a raw provider error string (+ optional HTTP status) to a typed code. */
    categorizeError(message: string, httpStatus?: number): EmailErrorCode;
}

interface EmailSendParams {
    to: string;
    from: string;
    subject: string;
    html: string;
    replyTo?: string;
    headers?: Record<string, string>;
    attachments?: EmailAttachment[];
}

interface EmailAttachment {
    filename: string;
    content: Buffer; // raw binary, not a base64 string or URL
    contentType?: string; // defaults to application/octet-stream
}

The second extras argument is typed per provider via ExtrasFor<K>. MTA uses MtaExtras (messageId, ipPool, engagementScore, dkimDomain) and Resend uses ResendExtras (idempotencyKey, forwarded as the Idempotency-Key header); SES takes no extras.

Send Result & Error Codes

A single attempt returns the EmailSendAttempt discriminated union — there is no { success, id?, error? } shape anymore:

type EmailSendAttempt =
    | { success: true; id: string }
    | { success: false; errorMessage: string; errorCode: EmailErrorCode };

enum EmailErrorCode {
    RATE_LIMIT = 'RATE_LIMIT',         // retryable
    SERVER_ERROR = 'SERVER_ERROR',     // retryable
    INVALID_RECIPIENT = 'INVALID_RECIPIENT',
    INVALID_SENDER = 'INVALID_SENDER',
    AUTH_FAILED = 'AUTH_FAILED',
    CONTENT_REJECTED = 'CONTENT_REJECTED',
    UNKNOWN = 'UNKNOWN',
}

isRetryableErrorCode(code) is the retry predicate: only RATE_LIMIT and SERVER_ERROR are retried; everything else is terminal.

Registry & Dispatch

Provider selection is no longer a switch over an env var. The registry maps each kind to its adapter, and providerFor() resolves it:

// lib/sendProviders/index.ts
export const SEND_PROVIDERS = {
    mta: mtaSendProvider,
    ses: sesSendProvider,
    resend: resendSendProvider,
} as const;

export function providerFor<K extends SendProviderKind>(kind: K): SendProviderModule<K> { /* ... */ }

All producers — the workpool worker, the campaign orchestrator's test send, the post-send resend, the automation email step, and the transactional HTTP send — go through one entry point, sendProviderDispatch():

// lib/sendProviders/dispatch.ts
export async function sendProviderDispatch<K extends SendProviderKind>(
    ctx: ActionCtx,
    kind: K,
    params: EmailSendParams,
    extras?: ExtrasFor<K>,
): Promise<DispatchResult>;

The dispatch helper:

  1. Runs the retry loop driven by module.retryDelays and the typed errorCode (retries RATE_LIMIT/SERVER_ERROR, gives up otherwise or on the last attempt).
  2. After every terminal outcome (success or exhausted retries), schedules recordSendResult to update that provider's health.
  3. Returns a DispatchResult carrying the final attempt, providerType, latencyMs, and attempts.

The EMAIL_PROVIDER env var is only a fallback default (still mta). It is consulted by resolveRoute() when an org has no providerRoutes config, no providers are enabled, or the chosen strategy returns nothing — see Routing below. It is no longer a hard switch on provider selection.

Routing Strategies

resolveRoute(routeConfig, healthStatuses) in lib/sendProviders/routing.ts reads an org's providerRoutes row, filters to enabled + recognized providers, looks up a strategy via strategyFor(), and returns a ResolvedRoute (providerType, optional ipPool, and a source of org_config | env_fallback). If no providerRoutes config resolves a provider, it falls through to the EMAIL_PROVIDER env var (source: env_fallback); if the env var is unset or unrecognized, resolveRoute returns null (fail-closed) — there is no implicit mta default.

StrategyBehaviourSource
singleAlways use the first enabled provider; ignores health.strategies/single/
priority_failoverWalk enabled providers in order; pick the first not currently down. Falls back to the first enabled provider if all are down or no health data exists.strategies/priority_failover/
workload_splitWeighted-random pick across enabled providers, excluding any that are down (weights default to 100). If every provider is down, still picks from the full set so the send leaves.strategies/workload_split/

Each strategy is a pure select(entries, ipPool, healthStatuses) function. Adding a fourth strategy is a one-folder change in lib/sendProviders/strategies/.

Provider Health

lib/sendProviders/health.ts records send outcomes into the providerHealth table (one row per provider kind). The dispatch helper is the only writer (via recordSendResult), and resolveRoute() is the only reader of the all-providers snapshot (getAllProviderHealth). Each record keeps a decaying success/failure count, a moving-average latency, and a status derived from thresholds:

StatusCondition
healthysuccess rate ≥ 90%
degraded50% ≤ success rate < 90%
downsuccess rate < 50%, or ≥ 5 consecutive failures

The priority_failover and workload_split strategies consult these statuses to steer traffic away from a down provider.

MTA Provider

The default provider sends through the Owlat MTA service rather than a third-party API: the adapter POSTs an email job to the MTA's /send HTTP endpoint, which handles queuing, rate limiting, DKIM signing, and MX delivery. It is a plain object, mtaSendProvider, in lib/sendProviders/mta/index.ts. Config comes from the MTA_API_URL and MTA_API_KEY env vars (not constructor fields); a missing var returns a failed attempt with AUTH_FAILED.

// lib/sendProviders/mta/index.ts
export const mtaSendProvider: SendProviderModule<'mta'> = {
    kind: 'mta',
    retryDelays: [1000, 5000], // consumed by the dispatch helper
    async sendEmail(params, extras) {
        // POST { messageId, to, from, subject, html, ipPool, engagementScore,
        //        dkimDomain, ... } to `${MTA_API_URL}/send` with a 30s timeout,
        // returning { success: true, id } or a failed EmailSendAttempt.
    },
    categorizeError(message, httpStatus) { /* 429 → RATE_LIMIT, 5xx → SERVER_ERROR, ... */ },
};

categorizeError maps by HTTP status first (429 → RATE_LIMIT, 5xx → SERVER_ERROR, 401/403 → AUTH_FAILED), then by substring matching on the body.

Full MTA documentation

See the MTA System page for details on the intelligence pipeline, bounce processing, IP warming, and configuration.

Resend Provider

resendSendProvider (lib/sendProviders/resend/index.ts) sends via the Resend SDK with a 30-second timeout and a lazily-cached client built from RESEND_API_KEY. Attachments are forwarded as { filename, content, content_type }. Errors are classified from Resend's error.name/statusCode (e.g. rate_limit_exceededRATE_LIMIT, invalid_to_fieldINVALID_RECIPIENT). retryDelays is [1000, 5000, 30000].

SES Provider

sesSendProvider (lib/sendProviders/ses/index.ts) sends via the AWS SDK, with a lazily-cached SESClient built from AWS_SES_REGION, AWS_SES_ACCESS_KEY_ID, and AWS_SES_SECRET_ACCESS_KEY. Two send paths:

  • No attachmentsSendEmailCommand with the HTML body.
  • With attachmentsSendRawEmailCommand with a manually-built multipart/mixed MIME message (the MIME builder is inlined in the module).

Errors are classified from the SDK error's name (e.g. ThrottlingRATE_LIMIT, MailFromDomainNotVerifiedINVALID_SENDER, MessageRejectedCONTENT_REJECTED). retryDelays is [1000, 5000, 30000].

Adding a fourth send provider is a one-folder change: create lib/sendProviders/<kind>/index.ts, add the literal to SendProviderKind, and register it in SEND_PROVIDERS. A compile-time check on the registry catches a missing method.

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. Convex code imports scanContent directly from @owlat/email-scanner (for example in apps/api/convex/campaigns/send.ts and apps/api/convex/transactional/lifecycle.ts) — there is no longer a lib/contentScanner.ts re-export wrapper.

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 Latin a)
  • 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 apps/api/convex/delivery/worker.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 urlReputationCache table (24h clean, 1h flagged)
  • Requires GOOGLE_SAFE_BROWSING_API_KEY environment 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/attachment endpoint
  • Convex delivery/worker.ts calls 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

FilePurpose
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/delivery/worker.tsAttachment validation + ClamAV scan integration
apps/api/convex/mediaAssets.tsUpload file validation
apps/mta/src/routes/scan.tsMTA scan endpoint

Domain Verification

Before sending, domains must be verified with proper DNS records (SPF, DKIM, DMARC). Per ADR-0018, domain code lives in apps/api/convex/domains/, and per-provider registration sits behind a SendingDomainProviderModule adapter seam in domains/providers/{mta,ses}/. The MTA handles DKIM signing directly; the SES adapter additionally registers the domain through the AWS SES identity API.

The domains/lifecycle.ts module is the single writer of domains.status and its companion fields. It exposes four entry points — create, transition, recordVerification, remove — and never branches on providerType; provider variation lives entirely behind domains/providers/.

SES Identity Management

When the SES adapter (domains/providers/ses/) registers a domain, it calls into 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 (the rua= reporting tag is omitted unless the operator sets MTA_DMARC_RUA)
MAIL FROM MXMXmail10 feedback-smtp.{region}.amazonses.com
MAIL FROM SPFTXTmailv=spf1 include:amazonses.com ~all

Domain Status Flow

User adds domainregistering
Domain record created in database
Provider registration
Lifecycle effect: register_with_provider (per-adapter action)
SuccessDNS records populatedpending
FailurelastRegistrationError setfailed
User clicks Verify
DNS + SES checks triggered
All checks passverified
Checks failfailed

Key Files

All paths below are under apps/api/convex/.

FilePurpose
domains/domains.tsDomain CRUD (create, remove, regenerateDnsRecords) + list/read queries; type definitions (DnsRecords, VerificationResults)
domains/lifecycle.tsSingle writer of domains.status; create/transition/recordVerification/remove and the register_with_provider / delete_with_provider effects
domains/dnsVerification.tsverifyDomain action — checks published DNS (SPF, DKIM array, DMARC, MAIL FROM MX/SPF) + per-provider check, then calls recordVerification
domains/queries.tsInternal read query (getDomainForRegistration) used by the verifier and register actions
domains/providers/index.tsSENDING_DOMAIN_PROVIDERS registry + providerFor(kind) (mta, ses)
domains/providers/{mta,ses}/Per-provider registration, DNS-record generation, identity persistence, and verification checks
lib/emailProviders/sesIdentity.tsSES identity API wrapper (pure library, called by the SES domain adapter)
lib/emailProviders/domainVerification.tsvalidateDomainForSending send-time enforcement

Verification Flow

  1. User adds a domain in Settings > Domains → domains.create
  2. domains/lifecycle.ts inserts the row at registering status and fires the register_with_provider effect
  3. The provider adapter (MTA or SES) registers the domain and builds the DNS records to publish; for SES this calls registerDomain + setupMailFromDomain
  4. Status transitions to pending with the provider's real DKIM tokens, and the per-provider identity sibling row is persisted
  5. User adds the DNS records with their DNS provider
  6. User clicks "Verify" → dnsVerification.verifyDomain checks all DNS records plus the provider's own verification status, then calls recordVerification
  7. The lifecycle reducer marks the domain verified only when the DNS rule passes AND the provider check reports verified (MTA has no extra check; SES requires 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 — 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

  1. Campaign Created - Template selected, audience chosen
  2. Domain Verified - Check sender domain is verified
  3. Content Scanned - Spam, phishing, homoglyph, and prohibited content analysis
  4. URL Reputation Checked - Google Safe Browsing API (if configured)
  5. Audience Resolved - Get eligible contacts (topic audiences honour double opt-in; segment audiences are not DOI-gated)
  6. HTML Rendered - Per-language rendering
  7. Attachments Validated - File type validation + ClamAV malware scan (if attachments present)
  8. Batch Processing - Send in batches with rate limiting
  9. Status Tracking - Track sent, delivered, opened, clicked

Code

The single live entry point is internal.campaigns.send.startCampaignSend — the Campaign send orchestrator (module) at apps/api/convex/campaigns/send.ts. It runs the prep pipeline (preflight → content scan → archive snapshot → audience resolution → A/B variant fanout → workpool enqueue) and is scheduled by the Campaign lifecycle (module) on → scheduled / → sending and by the daily scheduler tick (processScheduledCampaigns).

For A/B test campaigns, the first-phase send fans out a test cohort (2 × splitPercentage% of audience, split A/B). The held-back remainder is sent the winner's content via the sibling action internal.campaigns.send.sendCampaignWinnerToRemainder after campaigns.abTest.declareABTestWinner transitions the AB test lifecycle to winner_selected — see CONTEXT.md "Campaign send orchestrator (module)" for the full vocabulary.

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.