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"
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
| 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;
}
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:
- Adapters — one object-literal module per provider (
mta,ses,resend), each doing a single-attempt send and classifying its own errors. - Registry —
SEND_PROVIDERSmaps aSendProviderKindto its adapter;providerFor(kind)looks one up. - Routing —
resolveRoute()reads an org'sproviderRoutesconfig, picks a strategy, and returns the provider to use. - Dispatch —
sendProviderDispatch()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:
- Runs the retry loop driven by
module.retryDelaysand the typederrorCode(retriesRATE_LIMIT/SERVER_ERROR, gives up otherwise or on the last attempt). - After every terminal outcome (success or exhausted retries), schedules
recordSendResultto update that provider's health. - Returns a
DispatchResultcarrying the final attempt,providerType,latencyMs, andattempts.
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.
| Strategy | Behaviour | Source |
|---|---|---|
single | Always use the first enabled provider; ignores health. | strategies/single/ |
priority_failover | Walk 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_split | Weighted-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:
| Status | Condition |
|---|---|
healthy | success rate ≥ 90% |
degraded | 50% ≤ success rate < 90% |
down | success 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.
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_exceeded → RATE_LIMIT, invalid_to_field → INVALID_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 attachments —
SendEmailCommandwith the HTML body. - With attachments —
SendRawEmailCommandwith a manually-builtmultipart/mixedMIME message (the MIME builder is inlined in the module).
Errors are classified from the SDK error's name (e.g. Throttling → RATE_LIMIT, MailFromDomainNotVerified → INVALID_SENDER, MessageRejected → CONTENT_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 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 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
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
delivery/worker.tscalls 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/delivery/worker.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). 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:
| 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 (the rua= reporting tag is omitted unless the operator sets MTA_DMARC_RUA) |
| 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
All paths below are under apps/api/convex/.
| File | Purpose |
|---|---|
domains/domains.ts | Domain CRUD (create, remove, regenerateDnsRecords) + list/read queries; type definitions (DnsRecords, VerificationResults) |
domains/lifecycle.ts | Single writer of domains.status; create/transition/recordVerification/remove and the register_with_provider / delete_with_provider effects |
domains/dnsVerification.ts | verifyDomain action — checks published DNS (SPF, DKIM array, DMARC, MAIL FROM MX/SPF) + per-provider check, then calls recordVerification |
domains/queries.ts | Internal read query (getDomainForRegistration) used by the verifier and register actions |
domains/providers/index.ts | SENDING_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.ts | SES identity API wrapper (pure library, called by the SES domain adapter) |
lib/emailProviders/domainVerification.ts | validateDomainForSending send-time enforcement |
Verification Flow
- User adds a domain in Settings > Domains →
domains.create domains/lifecycle.tsinserts the row atregisteringstatus and fires theregister_with_providereffect- The provider adapter (MTA or SES) registers the domain and builds the DNS records to publish; for SES this calls
registerDomain+setupMailFromDomain - Status transitions to
pendingwith the provider's real DKIM tokens, and the per-provider identity sibling row is persisted - User adds the DNS records with their DNS provider
- User clicks "Verify" →
dnsVerification.verifyDomainchecks all DNS records plus the provider's own verification status, then callsrecordVerification - The lifecycle reducer marks the domain
verifiedonly when the DNS rule passes AND the provider check reports verified (MTA has no extra check; SES requiresSuccess)
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 (topic audiences honour double opt-in; segment audiences are not DOI-gated)
- 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
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
dataVariablesobject
Variables are replaced at send time during HTML rendering.