Skip to content

Email

Partial

The Email capability is Lukeflow's transactional-email stack. Its defining split: template management is a headless TypeScript library plus a thin engine data layer, while the actual send (and any future inbound) is an engine concern driven by Camunda workflow actions. Nothing renders HTML on the send path and nothing merges variables in Java — the browser compiles the artifact, Postmark owns the rendered HTML and does the merge at delivery time.

Three moving parts make this work:

  • @lukeflow/email-* — the headless EmailDoc block model, validation/repair, the typed variable contract, and the react-email renderer that compiles {subject, html, text, variables}.
  • luke-core-engine — two modules: capability/emailtemplate (template CRUD + versioning + publish-to-Postmark) and capability/email (the send orchestrator, the audit rows, Postmark clients, and the Camunda EmailActionHandler). Plus emailasset for image storage authZ.
  • luke-file-proxy — streams email image bytes to S3 and serves them from a durable public URL.
  • luke-agents — the email AI agent that drafts an EmailDoc from a natural-language brief.

See also the Email library reference, Core Engine, File Proxy, and Agents.

Components at a glance

The UI edits an EmailDoc through useEmailBuilder, compiles it with compileEmail, and hands the compiled html/text to the engine on check-in. The engine forwards them to Postmark and stores only the lightweight JSON — never the rendered HTML.

The EmailDoc model & compile pipeline

EmailDoc (email-core/src/emailDoc.ts) is a small, bounded document: a global Theme plus an ordered list (max 50) of blocks from a closed vocabularyheading | text | button | image | divider | spacer | footer. Every string may carry placeholders; those are the merge contract and are preserved literally through every path.

FunctionModuleWhat it does
validateEmailDoc(doc)email-coreFull integrity check → flat Problem[] (errors gate check-in, warnings advisory). Checks required props, https image sources, http(s)-or- button hrefs, block/field length caps, safe CSS colors. Never throws.
repairEmailDoc(doc)email-coreReturns a structurally-sound copy: fills theme defaults, clamps contentWidth to 480–700, drops unknown block types/props, caps at MAX_BLOCKS. Reports what was removed. Pure.
extractVariables(doc)email-coreDistinct names (first-seen order) across subject, preheader, block text/label/href/src, and footer.unsubscribeUrl.
reconcileVariables(doc, declared)email-coreThe canonical typed contract: every used enriched with its declaration (undeclared → {type:"string", required:true}), plus declared-but-unused appended. Deterministic.
validateVariables(template)email-coreFlags invalid names, duplicates, unknown types, default/type mismatches, unused and undeclared variables.
buildTemplateModel(template, values)email-coreBuilds the Postmark TemplateModel skeleton — one key per reconciled variable, seeded values → default → type-zero. This is what the send task fills.
mergePreview(html, values) / previewValues(template)email-corePreview-only merge — substitutes with HTML-escaped values so the operator sees rendered content. The published artifact keeps literal.
isValidColor / coerceColoremail-coreBounded CSS-color grammar (hex, rgb()/hsl(), named colors) — blocks url()/expression() injection into inlined style="".
compileEmail(input)email-reactRenders the doc through react-email's render() twice → inlined, DOCTYPE'd, table-based html + plain-text text; returns {subject, html, text, variables}. Self-repairing, deterministic, never sends.
useEmailBuilder()email-builderHeadless controller: owns doc + selection + undo/redo; every edit re-derives problems and the reconciled variables.

The variable contract is the seam between library and engine: variables[] is emitted as JSON alongside the compiled artifact so a send-time task can validate a TemplateModel before calling Postmark, while stay literal in html/text for Postmark's Mustachio merge.

Template lifecycle flow

Authoring is tenant-scoped (X-Tenant-Id, actor X-User-Id) and mirrors the Forms definition/version model. The engine stores only EmailDoc JSON; the browser compiles the HTML and the engine forwards it to Postmark, which owns the rendered template.

Endpoint / methodPurpose
createPOST /api/email-templatesNew EmailTemplate (status=DRAFT, unique code ET-XXXX-DDMMMYY).
saveDraftPUT /{id}/draftAutosave the working draftDoc (+ cached subject). Not pushed to Postmark.
checkInPOST /{id}/versionsSnapshot the draft as an immutable EmailTemplateVersion (doc only, no HTML column). First check-in auto-publishes; publish=true republishes. pushToPostmark() upserts the compiled bodies under a stable alias.
publishPOST /{id}/versions/{v}/publishRe-push an existing version and make it the published one.
retire / unretirePOST /{id}/retire, /unretireToggle RETIREDPUBLISHED/DRAFT.
clonePOST /{id}/cloneDuplicate as a fresh DRAFT ("Copy of …"); no versions or alias carried over.
softDelete / restore / purgeDELETE /{id}, POST /{id}/restore, DELETE /{id}/purgeTrash lifecycle; purge also deletes versions + audit.
sendTestPOST /{id}/send-testReuses EmailService.sendTemplate with the template's postmarkAlias + a supplied model. Requires a published version.
auditTrailGET /{id}/auditImmutable EmailTemplateAuditEvent feed with resolved actor names.
pushToPostmark() (private)Resolves the tenant's server token via EmailServerService.resolveServerToken, calls PostmarkTemplateClient.upsert, stamps postmarkAlias/postmarkTemplateId.

Persistence. luke_email_templates holds the editable draftDoc, cached subject, status, publishedVersion, and postmarkAlias. luke_email_template_versions holds each immutable doc snapshot plus its postmarkAlias/postmarkTemplateId — deliberately no HTML column, because Postmark owns the rendered HTML.

Send flow

Sending is fully implemented and drives Postmark. There are three entry points, all funnelling through EmailService, which records every attempt as a luke_email_messages audit row — QUEUED on accept, then flipped to SENT (with postmarkMessageId) or FAILED (with errorCode/errorMessage). A delivery failure is recorded, not thrown.

ComponentRole
EmailActionHandler.execute()The outbound-rail Camunda handler (capability() == "email"). Maps a workflow node's inputs (to/from/cc/bcc/replyTo/subject/htmlBody/textBody for raw; template/templateId/templateModel for template sends) — string inputs resolve against process variables via Placeholders.resolve — then calls sendTemplate (if alias/id present) or sendRaw. Returns {emailId}.
EmailService.sendRaw()Validates recipient/subject/body, resolves the send context, persists the row, submits HtmlBody/TextBody to Postmark.
EmailService.sendTemplate()Validates templateId/templateAlias, always includes a (possibly empty) TemplateModel — Postmark does the merge.
EmailServerService.resolveSendContext()Resolves which Postmark Server token to use + the verified company sender; enforces that a tenant may only send from its own verified domain.
PostmarkClientLow-level POST /email and /email/withTemplate client; returns SendResult{ok, messageId, errorCode, message}.
EmailController (/api/emails)Tenant-facing raw + template send and a paged, size-capped message history.
InternalEmailController (/api/internal/emails)The server-to-server (process-triggered) twin of the send endpoints, behind the internal-key filter.

There is no dedicated outbox table for email — the send is invoked synchronously from the Camunda action handler, and the EmailMessage row is the durable audit record. Inbound email is not built: there is no inbound controller, parser, or webhook receiver in the codebase today.

Email assets

Inline images can't be data-URI'd into email at scale, so they get a durable public URL:

  1. UploadEmailAssetProxyController (POST /api/email-assets, file-proxy) accepts a multipart image (≤5 MiB, image/* only), asks core's emailasset module to authorize ({assetId, storageKey}), streams the bytes to S3 while computing SHA-256, then finalizes.
  2. ServePublicEmailAssetController (GET /api/public/email-assets/{assetId}) is unauthenticated: the high-entropy assetId is the sole authorization. Core resolves it (READY assets only) to {tenantId, storageKey} and the bytes stream from S3 with a one-year immutable cache header — exactly what a recipient's <img src> needs.

The engine side (emailasset/InternalEmailAssetController, EmailAssetService) holds the authZ and state; bytes only ever flow through the file-proxy.

AI drafting

EmailAiAssistPanel (consumer-ui) talks to the email agent in luke-agents (POST /agents/email/chat, POST /agents/email/testdata). The LLM returns the full EmailDoc each turn; Python repair_doc clamps it to the same bounded contract the library enforces, so the UI always receives a valid document. are preserved literally — the model never fills them. /testdata generates realistic sample merge values for preview and test-send.

Component reference

ComponentTypeResponsibilityKey methods
EmailDoc / Theme / EmailBlockTS modelBounded block document (7 block types, one global theme)
emailDoc.tsemail-coreModel, validation, repair, variable extraction, color safetyvalidateEmailDoc, repairEmailDoc, extractVariables, parseEmailDoc, isValidColor
variables.tsemail-coreTyped variable / merge contractreconcileVariables, validateVariables, buildTemplateModel, previewValues, mergePreview
EmailRendereremail-reactreact-email renderer + browser/Node compilercompileEmail, Email, EmailRenderer
useEmailBuilderemail-builderHeadless builder state (doc/selection/undo-redo)addBlock, moveBlock, updateBlock, updateTheme
EmailBuilderemail-builderPalette · canvas · settings · Problems · preview slot
EmailTemplateBuilderPageconsumer-uiLive template designer wiring builder + renderer + AI + engine API
EmailTemplateControllerengineTemplate CRUD, versioning, publish, send-test, auditcreate, saveDraft, checkIn, publish, sendTest, pushToPostmark
EmailTemplate / EmailTemplateVersionJPA entityluke_email_templates / luke_email_template_versions
EmailServiceengineSend orchestrator; QUEUED→SENT/FAILED auditsendRaw, sendTemplate, submit
EmailActionHandlerengineCamunda outbound-rail email/send actionexecute, capability
EmailMessage / EmailStatusJPA entityluke_email_messages audit row + lifecycle constants
EmailServerServiceenginePer-tenant Postmark server token + verified senderresolveSendContext, resolveServerToken, provision
PostmarkClient / PostmarkTemplateClientenginePostmark send + template-upsert clientssend, sendTemplate, upsert, createIfAbsent
EmailAssetProxyController / PublicEmailAssetControllerfile-proxyImage upload to S3 + public serveupload, serve
EmailAssetService / InternalEmailAssetControllerengineEmail-asset authZ + resolveauthorize, finalizeUpload, publicResolve
EmailAgentluke-agentsAI EmailDoc drafting + test data/chat, /testdata

Endpoints

MethodPathPurposeAuth
POST/api/email-templatesCreate a templateTenant + EMAIL (write)
GET/api/email-templatesList (status/deleted filters)Tenant + EMAIL (read)
GET/api/email-templates/{id}Get oneTenant + EMAIL (read)
PATCH/api/email-templates/{id}Edit name/descriptionTenant + EMAIL (write)
PUT/api/email-templates/{id}/draftAutosave draft docTenant + EMAIL (write)
POST/api/email-templates/{id}/versionsCheck in + publishTenant + EMAIL (write)
GET/api/email-templates/{id}/versionsList versionsTenant + EMAIL (read)
POST/api/email-templates/{id}/versions/{v}/publishRe-publish a versionTenant + EMAIL (write)
POST/api/email-templates/{id}/retire · /unretireRetire / restore statusTenant + EMAIL (write)
POST/api/email-templates/{id}/cloneDuplicate as draftTenant + EMAIL (write)
DELETE/api/email-templates/{id} · POST …/restore · DELETE …/purgeTrash lifecycleTenant + EMAIL (write)
POST/api/email-templates/{id}/send-testSend a Postmark testTenant + EMAIL (write)
GET/api/email-templates/{id}/auditActivity feedTenant + EMAIL (read)
POST/api/emails · /api/emails/templateSend raw / template emailTenant + EMAIL (write)
GET/api/emails · /api/emails/{id}Paged history / one messageTenant + EMAIL (read)
POST/api/internal/emails · /templateProcess-triggered sendInternal key
POST/api/email-servers · GETProvision / read tenant Postmark serverTenant + EMAIL
POST/api/email-assets (file-proxy)Upload inline imageGateway identity
GET/api/public/email-assets/{assetId} (file-proxy)Serve image (public)assetId is bearer
POST/api/internal/email-assets/authorize · /{id}/finalize · /public/{id}/resolveAsset authZ (engine)Internal key
POST/agents/email/chat · /agents/email/testdataAI draft / test dataAgents gateway

Status & gaps

Template management, versioning, publish-to-Postmark, transactional send (tenant, internal, and Camunda-driven), the audit trail, inline-image assets, and AI drafting are all implemented and green. What remains:

  • Inbound email is not built — no receiver/parser/webhook exists; inbound is a documented future Camunda task, not code.
  • No async outbox for email — sends are synchronous from the action handler; the EmailMessage row is the durable record (Postmark webhooks for delivery/bounce status are not yet consumed).

See the Completeness Scorecard for the current rating, and the Email library page for the headless package details. Related services: Core Engine · File Proxy · Agents.

Lukeflow Manual · documentation snapshot July 2026