Skip to content

Email

Lukeflow's headless email-template management engine — a framework-agnostic EmailDoc model, a React renderer that compiles it to Postmark-ready HTML + text, and a WYSIWYG builder. It mirrors the structure of Forms and is vendored into the Consumer UI. Library (vendored) · headless

Repository: luke-email · Type: Headless library (monorepo) · Packages: email-core / email-react / email-builder

Overview (template management only)

luke-email owns exactly one thing: the template. An AI agent (see Agents (AI)) or the visual builder produces an EmailDoc; the renderer compiles it to inlined HTML + a text alternative plus a declared variables[] contract; the builder UI edits it. That is the whole surface.

Sending and receiving are out of scope — by design

These packages never send or receive mail. Email outbound and inbound are Camunda tasks in the Core Engine:

  • the outbound task reads the compiled artifact's variables[] JSON to validate the process TemplateModel, then pushes HTML + template alias to Postmark and sends;
  • the inbound task ingests replies (e.g. a Postmark inbound webhook) and correlates them back to a process instance.

stay literal in the published HTML/text — Postmark (Mustachio) performs the merge at send time. This library only produces the artifact the engine consumes.

This split keeps the Email capability cleanly layered: a pure, testable, zero-network template model here, and all delivery/orchestration in the engine where the transaction boundary and outbox live.

Architecture

The repo is a TypeScript npm-workspaces monorepo (packages/*) with three published packages built in dependency order (tsup, dual ESM/CJS + .d.ts).

PackageDepends onWhat it provides
@lukeflow/email-core(nothing — zero runtime deps)The headless model: the EmailDoc block vocabulary, validateEmailDoc/repairEmailDoc (tolerant, never throw), extractVariables/parseEmailDoc, the typed variable contract, the shared FONTS library, and the color/bounds/URL safety layer.
@lukeflow/email-reactemail-core, react-emailThe React renderer: EmailRenderer (live sandboxed iframe preview) and compileEmail(doc){ subject, html, text, variables } with Tailwind styles inlined.
@lukeflow/email-builderemail-core (react peer)The visual builder: EmailBuilder (palette · WYSIWYG canvas · settings · preview-with-sample-data · problems) plus the headless useEmailBuilder controller. Decoupled from email-react via a host renderPreview(doc, values) slot.

The EmailDoc model

A bounded document — a subject, optional preheader, a theme, and an ordered list (max 50) of blocks drawn from a closed vocabulary:

heading | text | button | image | divider | spacer | footer

Anything outside that vocabulary (or outside the declared variables[] contract) is stripped on repairEmailDoc, so the renderer can never choke on untrusted input. theme.fontFamily is a single id from the shared font library (FONTS: web-safe stacks plus popular Google fonts — Inter, Roboto, Montserrat, Playfair Display, Merriweather, JetBrains Mono, …). That one font is applied to every element (email clients don't reliably inherit), with the Google stylesheet linked in <head> and a real fallback stack always present. Read the list via FONTS / fontById(id) / fontStack(id).

The typed variable / merge contract

ts
EmailVariable {
  name: string;
  type: "string" | "number" | "url" | "date" | "boolean";
  required: boolean;
  default?: unknown;
  label?: string;
}
  • reconcileVariables(doc) → the canonical used ∪ declared set (stable order, never drops a used var).
  • validateVariables(...) → checks concrete values against the contract.
  • buildTemplateModel(template, values) → the Postmark TemplateModel skeleton (seeded values → default → type-zero) — the handoff a Camunda outbound task enforces in Java by reading the artifact's variables[] JSON.
  • previewValues(template) → realistic sample values for the builder preview.
  • mergePreview(html, values) → substitutes into rendered HTML with Postmark-faithful escaping, so the builder shows the actual recipient email (published html/text still keep literal).

Compile pipeline

EmailDoc ──validate/repair──▶ safe EmailDoc ──compileEmail──▶ { subject, html, text, variables }
   (email-core, never throws)                  (email-react, react-email + inlined Tailwind)

The result — literal preserved — is the durable artifact stored by the engine and later merged by Postmark.

Key features

  • Never-throw modelvalidateEmailDoc/repairEmailDoc are tolerant; malformed or hostile input is coerced to a safe, renderable document rather than raising.
  • Closed block vocabulary — a fixed 7-block set (max 50), so the renderer's surface is finite and auditable.
  • Typed variable contract — declared variables with types, required, and defaults; reconciled against usage and enforced downstream by the engine.
  • WYSIWYG builder — the canvas renders the email live as you build; each block is shown via BlockView inside an inert + aria-hidden overlay so clicks select/drag the row. Undo-redo, keyboard reorder, a Problems panel, and preview-with-sample-data (values edited inline or AI-generated via onGenerateTestData).
  • Zero-dependency coreemail-core ships no runtime dependencies.

Security / integrity model

GuardBehavior
URL allow-listOnly http(s) (images: https only) or a bare reach an href/src; data: / javascript: / vbscript: are dropped (belt over React's escaping).
Color grammarTheme/button colors are coerced to a safe set (hex, rgb/rgba, hsl/hsla, named); url(...), expression(...), etc. fall back to a default — no CSS injection into inlined style="".
Field boundsSubject / preheader / text / label / alt / href / src lengths and image width / spacer size are capped — a giant field can't DoS render or bloat the stored template.
HTML-escaped mergemergePreview substitutes variables with Postmark-faithful escaping.
Preview isolationEmailRenderer renders inside a sandboxed (sandbox="") iframe.

Technology

ConcernChoice
LanguageTypeScript 5.6 (ESM-first, dual ESM/CJS output)
Monoreponpm workspaces (packages/*), Node ≥ 18
Bundlertsup (ESM + CJS + .d.ts)
Renderingreact-email (@react-email/components + @react-email/render), React ≥ 18 peer
LintESLint 9 flat config — TypeScript + react-hooks + jsx-a11y
TestsVitest + Testing Library (jsdom) — ~82 tests across 10 files
ReleasesChangesets

Local development

bash
npm install
npm run build      # email-core → email-react → email-builder (tsup: dual ESM/CJS + d.ts)
npm run typecheck  # run AFTER build — react/builder resolve email-core via its built dist
npm run lint       # eslint (flat config): TS + react-hooks + jsx-a11y
npm test           # vitest across packages

Build before typecheck

dist/ is gitignored and absent on a fresh checkout, yet typecheck and the tests read each package's built dist/*.d.ts. Always npm run build first; the root build script builds in dependency order.

CI (.github/workflows/ci.yml) runs build → typecheck → API-surface guard (npm run api) → size budget (npm run size) → lint → test, plus a blocking supply-chain gate (npm audit --omit=dev --audit-level=high, CycloneDX SBOM). A separate security-scan.yml runs Semgrep / gitleaks / Trivy. The API-surface guard snapshots each package's public .d.ts in api-reports/, so an accidental export/signature change fails CI and can't silently break the vendored consumer.

Consumption

The Consumer UI consumes these as vendored built dist under luke-consumer-ui/vendor/@lukeflow/<pkg>/ — not as workspace dependencies. After a change here:

bash
npm run build   # in luke-email
# copy dist/index.{js,cjs,d.ts,d.cts} (+ styles.css for the builder)
#   into luke-consumer-ui/vendor/@lukeflow/<pkg>/
# then build consumer-ui

The AI drafting path (an Agents (AI) endpoint that emits an EmailDoc) and the builder both feed the same model, so a document produced by either is interchangeable.

Status & gaps

Alpha — packages are at 0.1.0-alpha.0, but the library was hardened to Forms-level rigor: the same enforceable public-API-surface guard and bundle-size budget, a full docs suite (SECURITY.md, docs/SECURITY.md, docs/USAGE.md), and property/fuzz + injection-safety tests. email-core alone carries 66 tests (the fuzz suites run 400 adversarial iterations each).

  • In scope and done: the EmailDoc model + repair, the typed variable contract, the compile pipeline, the WYSIWYG builder, and the security/integrity guards — all fuzz-tested to never throw and to produce a structurally valid, bounded, idempotent document.
  • Gated in CI: API-surface drift, bundle-size regressions, supply-chain advisories, and SAST/secret/dependency scans.
  • Deliberately out of scope of the library: actual send (built in the Core Engine via Postmark — see the Email deep-dive) and inbound (not yet built).
  • Remaining caveats: version pre-1.0, UNLICENSED, and consumed via vendored dist (manual copy on each change) rather than a published package.

See the Completeness Scorecard for where this library sits against the rest of the platform.

Lukeflow Manual · documentation snapshot July 2026