Module kit / Messaging, email and SMS
Email Template Editor
messaging-email-sms/ui/email-template-editorlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1, monaco-editor-react-4 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope |
| Renders | messaging-email-sms/email-transport, messaging-email-sms/workspace-smtp-relay, messaging-email-sms/email-templates, messaging-email-sms/email-branding, messaging-email-sms/sms-transport, messaging-email-sms/telegram-alerts |
| Entry | alchemy_labs/frontend/src/pages/AdminEmailTemplateEditor.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12 |
| Version | 1 |
Purpose
The admin's own authoring tool for transactional email: a platform admin opens /admin/communication/email/new or /admin/communication/email/{id}/edit and, in one three-column screen, writes a template's name, subject, preview text, category, active flag, named variables and HTML/plain-text bodies, watches a live iframe re-render the HTML as they type, inserts a variable as a {{name}} placeholder with one click, saves it, and can immediately fire a real test send to any address. The invariant that makes it trustworthy: nothing in the preview is a guess: the iframe renders the admin's own body_html field verbatim, and Save/Load both round-trip the identical EmailTemplate shape GET/POST/PUT /api/templates/emails* persist, so what an admin previews is byte-for-byte what a later render_template() call substitutes and sends (backend email_service.py 235 to 253).
It is the only page in Labs that writes an email_templates row, which makes it the frontend of messaging-email-sms/email-templates (the table, the routes, render_template's {{var}} substitution) and, through the test-send button, of messaging-email-sms/email-transport (whichever platform transport selected_transport() picks, Application logic). The other four sibling backend recipes in this domain (workspace-smtp-relay, email-branding, sms-transport, telegram-alerts) are not rendered by this page at all: at 62ba4fc there is no other frontend recipe in messaging-email-sms for them to point to instead (see "Not this recipe"), so they are listed here as the domain's current frontend module, not as something this screen actually shows.
When to use
Trigger phrases: "the email template editor", "edit the welcome email", "create a new email template", "send a test email", "the HTML editor for emails", "why is the preview blank", "insert a variable into the template", "the admin communication page". Surface: SPA, an admin-only page behind AdminRoute (frontend) and AdminUser (backend, every route in email_templates_router.py), reached at /admin/communication/email/new and /admin/communication/email/:templateId/edit (App.tsx 852 to 866 at 62ba4fc), both lazy chunks.
Not this recipe: /admin/communication (AdminCommunication.tsx, the hub page with the Email/SMS tabs and stat cards this editor is opened from, a separate route this page never renders); the SMS template editor AdminCommunication.tsx navigates to at /admin/communication/sms/{id} and /admin/communication/sms/{id}/edit (lines 413, 442); neither path has a matching <Route> in App.tsx at 62ba4fc, so both are dead links today, not merely out of scope (Gotchas); the hub's own /admin/communication/create?type=sms button (line 400), the same dead-link family; workspace SMTP relay and workspace email branding, both configured through workspace_router.py's own endpoints and rendered on the workspace's own settings surface for a tenant configuring their own outgoing mail, a different audience entirely (workspace-smtp-relay, email-branding); Telegram alerts, whose service code (telegram_service.py, service_desk/telegram_bot.py) has no /admin/* route or page component anywhere in the frontend at 62ba4fc; and SMS transport itself, whose own template list lives behind the dead SMS route above and is otherwise not reachable from any page.
Stack assumptions
S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19.2.0, react-router-dom 7.8.2, tailwindcss 3.4.17, lucide-react 0.563.0, axios (via the api singleton), @monaco-editor/react ^4.7.0 (the HTML editor, theme="vs-dark", language="html", not one of the four packages a demo may import from, Frontend). Component library: @prima-modules/alchemy-ui, of which the page uses Button, Heading, Text, Input, Card, CardContent, Badge, Spinner, Select, Toggle (AdminEmailTemplateEditor.tsx 18 to 29). Data-fetch style: the api singleton (S2), the Bearer header from accessToken in localStorage; none of the four routes this page calls read X-Workspace-ID (Security and tenancy: this is a platform-wide admin console, not workspace-scoped). Copy language: English, the console's own register (matching identity-auth/ui/admin-users-console, not the de-CH of the tenant-facing pages this domain's own email templates render for).
Data model
Client state (AdminEmailTemplateEditor.tsx lines 61 to 83), all in the page component, nothing persisted beyond the two auth tokens S2 already owns:
interface EmailTemplate { // GET /api/templates/emails/{id}, unchanged from the route's JSON
id: string; name: string;
subject: string | null; preview_text: string | null;
body_html: string | null; body_text: string | null;
cta_button_text: string | null; cta_button_url: string | null;
category: string | null; is_active: boolean;
variables: string[] | null;
created_at: string; updated_at: string | null;
}
loading: boolean (initially !isNewTemplate); saving: boolean; sendingTest: boolean;
error: string | null; success: string | null;
template: Partial<EmailTemplate>; // seeded with 10 literal empty-string/true/[] defaults, lines 67 to 78
testEmail: string; previewHtml: string; showVariables: boolean (initially true);
iframeRef: React.RefObject<HTMLIFrameElement>;
isNewTemplate = templateId === 'new' (line 59) is derived from the route param, not stored; it gates the initial loading value, the fetch effect, the header copy, and the test-email button's disabled state and hint sentence, all four independently re-deriving the same boolean rather than reading one shared flag.
The demo's props (demo.tsx): { viewer, workspace, mode: 'new' | 'edit', status: 'loading' | 'ok', template: Partial<EmailTemplate>, error?, success?, sendingTest?, testEmail?, showVariables? }; mode is the demo's name for isNewTemplate inverted into an enum, since a demo frame has no route param to derive it from. body_html, variables, testEmail and showVariables each seed a local useState in the demo the same way voucherCode does in billing-credits/ui/subscription-manager's demo, since clicking a variable chip, typing an email address and toggling the section have to update visibly within one static frame; every other field in template (name, subject, preview text, category, active) renders from the prop directly (defaultValue / defaultChecked) and is not wired to change the frame's own state.
Server-side, the table this page writes: email_templates (client_onboarding_model.py 246 to 260); id UUID primary key, name unique + not-null, subject/preview_text/cta_button_text/cta_button_url nullable strings, body_html/body_text nullable text, category a Postgres enum (EmailCategory, core/enums.py 134 to 140, the exact five values CATEGORY_OPTIONS hard-codes), is_active boolean default true, variables a JSON array, created_at/updated_at server-set timestamps the page never writes to directly. Not stored here: rendered/sent copies of a template (email_logs, a different table, written only by an actual send, not by this editor) and the workspace-level branding shell or SMTP config this domain's other two backend recipes own.
Browser storage: nothing of its own, relies on S2's accessToken and refreshToken in localStorage, the same as every other Labs admin page.
API surface
Contracts consumed, one read and three writes (of the six routes email_templates_router.py exposes, this page never calls DELETE /{id}, POST /{id}/preview or POST /test-with-variables, all AdminUser-gated the same as the four it does call):
| Call (line) | Route | Auth / tenancy | Parsed | Rendered as |
|---|---|---|---|---|
api.get(\/api/templates/emails/${templateId}`)` (118) | GET /api/templates/emails/{template_id} | AdminUser (no workspace) | the whole body into template, replacing every field including any unsaved local edit | the three-column form's own fields, once loading clears |
api.post('/api/templates/emails', payload) (148) | POST /api/templates/emails | AdminUser | response.data.id only, for the redirect | the success card, then (1s later) a navigate to /admin/communication/email/{new id}/edit |
api.put(\/api/templates/emails/${templateId}`, payload)` (155) | PUT /api/templates/emails/{template_id} | AdminUser | nothing (204-shaped in effect; the response body is discarded) | the success card only |
api.post('/api/templates/emails/test', {recipient_email, template_id}) (180) | POST /api/templates/emails/test | AdminUser | nothing; the response body ({status, message}) is never read | the fixed sentence Test email sent to ${testEmail} regardless of what the server actually returned (Application logic, Gotchas) |
Error bodies as this page reads them: a duplicate name on create is a plain HTTPException(status_code=409, detail="An email template with this name already exists.") (email_service.py 305 to 307); a missing row on load, update or test is a 404 with a similar string detail; every one of these is wrapped by app_fastapi.py's handler (create_error_response, line 130 to 136 of error_schemas.py) into the Labs envelope under the key error, not detail. All four of this page's catch blocks read err.response?.data?.detail (lines 122, 159, 187), which is undefined against that envelope, so none of these specific server messages (the 409 name collision, the 404s, send_test_email's own "Failed to send test email: {e}") ever reaches the screen: instead, one of the page's three fixed fallback sentences does ("Failed to load template", "Failed to save template", "Failed to send test email"), the one exception being "Please enter a test email address", a client-side check that runs before any request and is never swallowed (line 167). This is the same envelope mismatch billing-credits/ui/subscription-manager's and identity-auth/ui/admin-users-console's Gotchas document for their own pages. No WebSocket, no postMessage.
Application logic
- Mount:
isNewTemplatederives from the route param; in edit modeloadingstartstrueand auseEffectfiresfetchTemplate()once, in new-template modeloadingstartsfalseand the three-column layout renders immediately over the literal empty defaults (lines 61, 93 to 97). - While
loading(edit mode only): the page returns<AdminNavigation><div class="min-h-screen">…<Spinner lg/>…</div></AdminNavigation>, so the shell is already on screen and only the content area is a single centred spinner (lines 219 to 229); the same "shell first, spinner inside" conventionbilling-credits/ui/subscription-manageruses, not the bare early returnidentity-auth/ui/admin-users-consoleuses. fetchTemplate: on success,setTemplate(response.data)replaces the entire form state with the server row (any in-flight local typing that has not been saved is not merged, it is overwritten); on any throw, the mismatched-envelope fallback "Failed to load template" is set andtemplateis left at whatever it already was (the empty defaults, since nothing else had set it yet);loadingclears either way (115 to 126).- Preview:
template.body_htmlchanges are pushed through a 500ms localdebounce()intopreviewHtml(86 to 101); a second effect writespreviewHtmlinto the iframe imperatively via a ref (doc.open(); doc.write(...); doc.close()), falling back to a fixed placeholder paragraph when it is empty (103 to 113); so a brand new template's preview is never a blank frame, it is that placeholder sentence, until the admin types. handleSave: builds a plain payload fromtemplate, coercing every optional string field tonullwhen falsy (134 to 145);POSTwhenisNewTemplate(success sentence, then a bare 1-secondsetTimeoutnavigate to the new row's own edit route with no cancel, 147 to 153),PUTotherwise (154 to 157);savingtoggles the header button between its icon+label and a spinner+"Saving..." (161, 251 to 254); failure sets the mismatched-envelope fallback (159).handleSendTest: refuses an emptytestEmailand a still-new (unsaved) template client-side before any request leaves (166 to 174);POST /testwith{recipient_email, template_id}; on any 200,successis set to the fixed sentenceTest email sent to ${testEmail}with no read of the response body at all (176 to 191); even when the backend's ownsend_test_emailreturned{"status": "warning", "message": "Email not sent (transport '…' not configured or error occurred; see server logs)"}on that same 200 (email_service.py401 to 410), which this page has no code path that would ever surface.insertVariable: appends`{{${varName}}}`to the end ofbody_html, with no awareness of where the Monaco cursor actually sits (193 to 199).addVariable:window.promptfor a name, lower-cased and underscore-joined, pushed ontovariables(201 to 210).removeVariable: filters it back out (212 to 217). None of the three touches the network; all three are local array/string edits that only reach the server on the next Save.
Invariants
- The preview is the admin's own HTML, unmodified: the iframe renders
body_htmlthroughdoc.write()with no sanitisation and no server round-trip, so what is on screen is exactly what the next Save would persist, not a server-rendered guess. - A new template has no server identity until the first successful
POST: every edit before that (variable inserts, HTML typing) lives in React state alone, and a reload before Save loses all of it, sinceisNewTemplateskips the fetch that would otherwise recover anything. - A successful test send is reported identically whether or not an email actually left the platform:
handleSendTestreads nothing from the response body (step 6), so a{"status": "warning", ...}200 and a genuine delivery produce the same green sentence. - The raw server error is never shown, only one of three fixed sentences (load, save, test) or the one client-side "Please enter a test email address" check that runs before any request; the same envelope mismatch
billing-credits/ui/subscription-managerandidentity-auth/ui/admin-users-consoledocument for their own catch blocks (API surface). - Save has no confirmation of any kind, unlike a destructive action elsewhere in Labs (
billing-credits/ui/subscription-manager's cancel asks first):PUTsilently overwrites the stored row, and a new template's redirect fires on a bare timer with nothing to cancel it.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
loading server rendered, hydrated in view new-template server rendered, hydrated in view populated server rendered, hydrated in view sending-test server rendered, hydrated in view error server rendered, hydrated in view messaging-email-sms/ui/email-template-editor/demo.tsx
/**
* Live demo for messaging-email-sms/ui/email-template-editor.
*
* Labs' AdminEmailTemplateEditor.tsx (main, 62ba4fc, 460 lines) with its one
* read (GET /api/templates/emails/{id}, skipped entirely for a new template)
* and three writes (create, update, send-test) replaced by props: the fetch
* status, the mode (new vs edit), the template row, the top-level error and
* success strings, and the two busy flags (saving is not surfaced, Gotchas
* below; sendingTest is). Rendered inside the shell demo's component the way
* every private page is (AdminNavigation.tsx is a thin delegator to AppShell,
* same as Navigation.tsx), with the "Operate" rail entry active and
* `/admin/communication` highlighted in the column (nav.tsx's `admin` domain
* lists `/admin/communication` among its paths).
*
* What is kept from the source: the header (Back, the Create/Edit heading,
* the template name subheading, the Save button), the red/green alert cards,
* the three-column grid (settings sidebar, HTML editor, live preview), every
* settings field (name, subject, preview text, category Select, active
* Toggle), the variable chips with their insert-on-click and hover-to-remove
* affordance, the "+ Add Variable" row, the preview iframe, and the
* send-test-email row with its own disabled/spinner states. All copy is the
* source's, English (this is the platform operator's own console, not a
* tenant surface, the same register admin-users-console's demo documents).
*
* What is different, and why:
* - `@monaco-editor/react`'s `<Editor>` (vs-dark theme, HTML language) is
* replaced by a plain `<textarea>` styled to match (dark background,
* monospace, the same padding and font size): Monaco is not one of the
* four packages a demo may import from (AUTHORING.md "The exports"), and
* its default loader fetches its worker bundle from a CDN at runtime,
* which a demo that touches no network cannot do either (Deploy & ops).
* - The ref-based `doc.open()/write()/close()` into the iframe (source lines
* 104 to 113) is replaced by React's own `srcDoc` prop, recomputed on
* every keystroke instead of through the source's 500ms lodash-style
* debounce: a demo frame has no real user typing at production speed to
* debounce against, and `srcDoc` needs no imperative ref at all. The same
* fixed placeholder paragraph the source falls back to (line 109) is
* reproduced verbatim for an empty body.
* - `insertVariable` and `removeVariable` (source lines 193 to 217) are pure
* client-side array/string edits with no network step, so unlike the
* inert write-triggering buttons in the sibling recipes, they are wired to
* real local state here: clicking a chip appends `{{name}}` to the HTML
* textarea and the live preview updates immediately, which is the one
* genuinely interactive thing this recipe exists to demonstrate.
* - `addVariable` (source lines 201 to 210) calls `window.prompt`, which a
* demo frame has no user to answer; the "+ Add Variable" button renders
* but does nothing, the same convention admin-users-console's demo uses
* for its own prompt- and confirm-gated actions.
* - The Save button's own busy state (`saving`, source lines 128 to 163) is
* a real, distinct boolean the source renders (a spinner in place of the
* Save icon, "Saving..." in place of the label) but is not given its own
* state here: it is the identical spinner-in-button pattern the
* `sending-test` state already demonstrates on a sibling button, and
* giving it a state of its own would only repeat that frame with the
* right-hand panel unchanged. The prop and the JSX are kept; no state sets
* it. The green success card is the same kind of gap: `error` and
* `success` are mutually exclusive in the source (every handler clears
* `error` before setting `success` and vice versa, Application logic), so
* demonstrating it would cost a whole extra frame to show a banner that
* is visually identical to the red one already shown by `error`, just
* green; the prop and the JSX are kept, no state sets it either.
*/
import React, { useState } from 'react';
import {
AlertCircle,
ArrowLeft,
ChevronDown,
ChevronUp,
CheckCircle,
Code,
Eye,
Save,
Send,
Variable,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, CardContent, Heading, Input, Select, Spinner, Text, Toggle } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';
/* ---- props --------------------------------------------------------------- */
/** GET /api/templates/emails/{id}'s shape, unchanged from the route's JSON (EmailTemplateResponse, communication_schemas.py 29 to 35). */
export interface EmailTemplate {
id: string;
name: string;
subject: string | null;
preview_text: string | null;
body_html: string | null;
body_text: string | null;
cta_button_text: string | null;
cta_button_url: string | null;
category: string | null;
is_active: boolean;
variables: string[] | null;
created_at: string;
updated_at: string | null;
}
export interface Props {
/** The signed-in admin, for the shell's account row and the agency group. */
viewer: ShellUser;
workspace: ShellWorkspace;
/** `new` skips the fetch entirely (isNewTemplate, line 59); `edit` is reached with a real `templateId`. */
mode: 'new' | 'edit';
/** `loading` is only reachable in `edit` mode, before the fetch resolves (line 61, 93 to 97). */
status: 'loading' | 'ok';
/** The form's current values; `new` mode starts from the same literal defaults as the source (lines 67 to 78). */
template: Partial<EmailTemplate>;
/** The top-level error card's text (line 259 to 266): a failed load, save or test send, always one of three fixed sentences (Gotchas). */
error?: string | null;
/** The top-level success card's text (line 268 to 275): kept in the rebuild, no state sets it (see header comment). */
success?: string | null;
/** The Send Test Email button's busy flag: disabled, the atom's own spinner in place of the icon (line 430 to 436). */
sendingTest?: boolean;
/** The test-email input's starting value; typing updates it locally, same convention as the sibling recipes' controlled inputs. */
testEmail?: string;
/** Whether the Variables section starts expanded (source default: true, line 82). */
showVariables?: boolean;
}
/* ---- sample data ----------------------------------------------------------- */
const viewer: ShellUser = { name: 'Sami Keller', email: 'sami@example.com', isAdmin: true, isDeskEmployee: false };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Studio Nordlicht', slug: 'studio-nordlicht' };
/** The literal defaults `useState` seeds `template` with for a brand new template (lines 67 to 78). */
const emptyTemplate: Partial<EmailTemplate> = {
name: '',
subject: '',
preview_text: '',
body_html: '',
body_text: '',
cta_button_text: '',
cta_button_url: '',
category: '',
is_active: true,
variables: [],
};
/** A workspace-invite email, three named variables, an onboarding category. */
const sampleTemplate: EmailTemplate = {
id: 'a83a1a9e-2b7a-4b39-9d0e-6a7e0d6b9a52',
name: 'workspace_invite',
subject: 'Willkommen bei {{company_name}}',
preview_text: 'Dein Zugang ist bereit — leg direkt los.',
body_html:
'<div style="font-family: sans-serif; padding: 24px; color: #1a1a1a;">\n' +
' <h1 style="font-size: 20px;">Hallo {{first_name}},</h1>\n' +
' <p>Dein Workspace bei {{company_name}} ist eingerichtet.</p>\n' +
' <p>\n' +
' <a href="{{magic_link_url}}" style="background:#7c5cff;color:#fff;padding:12px 20px;border-radius:8px;text-decoration:none;">\n' +
' Jetzt loslegen\n' +
' </a>\n' +
' </p>\n' +
'</div>',
body_text: 'Hallo {{first_name}}, dein Workspace bei {{company_name}} ist eingerichtet: {{magic_link_url}}',
cta_button_text: 'Jetzt loslegen',
cta_button_url: '{{magic_link_url}}',
category: 'onboarding',
is_active: true,
variables: ['first_name', 'company_name', 'magic_link_url'],
created_at: '2026-08-02T09:14:00Z',
updated_at: '2026-09-10T15:41:00Z',
};
const base: Props = {
viewer,
workspace,
mode: 'edit',
status: 'ok',
template: sampleTemplate,
error: null,
success: null,
sendingTest: false,
testEmail: '',
showVariables: true,
};
export const states = defineStates<Props>({
'loading': { label: 'Edit mode, first fetch: the spinner inside the shell, before any row is known (line 219 to 229)', props: { ...base, mode: 'edit', status: 'loading', template: emptyTemplate }, height: 620, viewport: 1440 },
'new-template': { label: '"Create Email Template": the literal empty defaults, no variables, the test-email row disabled with its own hint sentence', props: { ...base, mode: 'new', template: emptyTemplate }, height: 900, viewport: 1440 },
'populated': { label: 'An existing template loaded: three variable chips, a rendered preview, the test-email row enabled', props: base, height: 900, viewport: 1440 },
'sending-test': { label: 'Send Test Email in flight: the button disabled with its own spinner, the typed address kept in the box', props: { ...base, sendingTest: true, testEmail: 'qa+preview@example.com' }, height: 900, viewport: 1440 },
'error': { label: 'The row failed to load: the red fallback sentence over the same empty defaults `new-template` shows, in `edit` mode', props: { ...base, template: emptyTemplate, error: 'Failed to load template' }, height: 900, viewport: 1440 },
});
/* ---- constants (source lines 47 to 54) ------------------------------------- */
const CATEGORY_OPTIONS = [
{ value: '', label: 'Select category' },
{ value: 'onboarding', label: 'Onboarding' },
{ value: 'project_workflow', label: 'Project Workflow' },
{ value: 'reminder', label: 'Reminder' },
{ value: 'invoice', label: 'Invoice' },
{ value: 'cold_outreach', label: 'Cold Outreach' },
];
const PREVIEW_PLACEHOLDER = '<p style="color: #666; text-align: center; margin-top: 50px;">Email preview will appear here</p>';
/* ---- the page --------------------------------------------------------------- */
function Editor({
mode,
status,
template,
error,
success,
sendingTest = false,
testEmail: testEmailInitial = '',
showVariables: showVariablesInitial = true,
}: Omit<Props, 'viewer' | 'workspace'>) {
const isNewTemplate = mode === 'new';
const [html, setHtml] = useState(template.body_html || '');
const [variables, setVariables] = useState<string[]>(template.variables || []);
const [testEmail, setTestEmail] = useState(testEmailInitial);
const [showVariables, setShowVariables] = useState(showVariablesInitial);
if (status === 'loading') {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Spinner size="lg" />
<Text muted className="mt-4">Loading template...</Text>
</div>
</div>
);
}
const insertVariable = (varName: string) => {
setHtml((prev) => prev + `{{${varName}}}`);
};
const removeVariable = (varName: string) => {
setVariables((prev) => prev.filter((v) => v !== varName));
};
return (
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost">
<ArrowLeft className="h-5 w-5 mr-2" />
Back
</Button>
<div>
<Heading level={2}>
{isNewTemplate ? 'Create Email Template' : 'Edit Email Template'}
</Heading>
<Text muted size="sm">{template.name || 'Untitled template'}</Text>
</div>
</div>
<div className="flex items-center gap-3">
<Button variant="primary">
<Save className="h-4 w-4 mr-2" />
Save Template
</Button>
</div>
</div>
{/* Alerts */}
{error && (
<Card variant="glass" padding="md" className="mb-4 !bg-red-50 !outline-red-200">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-600" />
<Text className="text-red-800">{error}</Text>
</div>
</Card>
)}
{success && (
<Card variant="glass" padding="md" className="mb-4 !bg-green-50 !outline-green-200">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<Text className="text-green-800">{success}</Text>
</div>
</Card>
)}
{/* Three Column Layout */}
<div className="grid grid-cols-12 gap-6">
{/* Left Sidebar - Settings */}
<div className="col-span-12 lg:col-span-3">
<Card variant="glass" padding="md" className="sticky top-6">
<Heading level={4} className="mb-4">Template Settings</Heading>
<div className="space-y-4">
<div>
<Text size="sm" weight="medium" className="mb-1">Template Name *</Text>
<Input defaultValue={template.name || ''} placeholder="e.g., welcome_email" />
<Text muted size="xs" className="mt-1">Unique identifier for this template</Text>
</div>
<div>
<Text size="sm" weight="medium" className="mb-1">Subject Line</Text>
<Input defaultValue={template.subject || ''} placeholder="e.g., Welcome to Alchemy Zurich!" />
</div>
<div>
<Text size="sm" weight="medium" className="mb-1">Preview Text</Text>
<Input defaultValue={template.preview_text || ''} placeholder="Text shown in email preview" />
</div>
<div>
<Text size="sm" weight="medium" className="mb-1">Category</Text>
<Select value={template.category || ''} options={CATEGORY_OPTIONS} />
</div>
<div className="flex items-center justify-between">
<Text size="sm" weight="medium">Active</Text>
<Toggle defaultChecked={template.is_active ?? true} />
</div>
{/* Variables Section */}
<div className="pt-4 border-t border-line">
<button
onClick={() => setShowVariables(!showVariables)}
className="flex items-center justify-between w-full text-left"
>
<div className="flex items-center gap-2">
<Variable className="h-4 w-4 text-primary" />
<Text size="sm" weight="medium">Variables</Text>
<Badge variant="default" size="sm">{variables.length}</Badge>
</div>
{showVariables ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
{showVariables && (
<div className="mt-3 space-y-2">
<Text muted size="xs">Click a variable to insert it into the HTML</Text>
<div className="flex flex-wrap gap-2">
{variables.map((v) => (
<div key={v} className="group relative">
<button
onClick={() => insertVariable(v)}
className="px-2 py-1 bg-primary/10 text-primary rounded text-xs font-mono hover:bg-primary/20 transition-colors"
>
{`{{${v}}}`}
</button>
<button
onClick={() => removeVariable(v)}
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
x
</button>
</div>
))}
</div>
<Button variant="ghost" size="sm" className="w-full mt-2">
+ Add Variable
</Button>
</div>
)}
</div>
</div>
</Card>
</div>
{/* Center - HTML Editor (Monaco in the source, a plain textarea here, header comment) */}
<div className="col-span-12 lg:col-span-5">
<Card variant="glass" className="overflow-hidden">
<div className="px-4 py-3 border-b border-line flex items-center gap-2">
<Code className="h-4 w-4 text-primary" />
<Text size="sm" weight="medium">HTML Editor</Text>
</div>
<textarea
value={html}
onChange={(e) => setHtml(e.target.value)}
spellCheck={false}
className="w-full h-[420px] bg-[#1e1e1e] text-[#d4d4d4] font-mono text-[13px] p-4 outline-none resize-none"
/>
</Card>
</div>
{/* Right - Preview */}
<div className="col-span-12 lg:col-span-4">
<Card variant="glass" className="overflow-hidden">
<div className="px-4 py-3 border-b border-line flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" />
<Text size="sm" weight="medium">Live Preview</Text>
</div>
<div className="h-[320px] bg-stone-100">
<iframe
srcDoc={html || PREVIEW_PLACEHOLDER}
className="w-full h-full border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
{/* Test Email Section */}
<CardContent className="border-t border-line">
<Text size="sm" weight="medium" className="mb-2">Send Test Email</Text>
<div className="flex gap-2">
<Input
type="email"
value={testEmail}
onChange={(e) => setTestEmail(e.target.value)}
placeholder="test@example.com"
className="flex-1"
/>
<Button variant="outline" disabled={sendingTest || isNewTemplate}>
{sendingTest ? <Spinner size="sm" /> : <Send className="h-4 w-4" />}
</Button>
</div>
{isNewTemplate && (
<Text muted size="xs" className="mt-2">Save the template first to send test emails</Text>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
return (
<Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/admin/communication/email/tmpl_1/edit" hasTour={false} hideChrome={galleryMode}>
<Editor {...page} />
</Shell>
);
}
Component tree, from the recipe
<Route /admin/communication/email/new, /admin/communication/email/:templateId/edit> AdminRoute (children)
AdminNavigation = AppShell ("Operate" active, /admin/communication in the rail)
AdminEmailTemplateEditor
[loading] div.min-h-screen > Spinner lg + Text "Loading template..." (edit mode only, before the three-column layout, line 219 to 229)
header Button ghost "Back" (ArrowLeft) + Heading 2 (Create|Edit Email Template) + Text muted sm (name | "Untitled template")
Button primary "Save Template" (Spinner sm + "Saving..." | Save icon, line 251 to 254)
[error] Card glass md !bg-red-50: AlertCircle + Text red (one of three fixed fallback sentences, Gotchas)
[success] Card glass md !bg-green-50: CheckCircle + Text green (never the raw server message either)
grid-cols-12 gap-6
left col-span-3 Card glass md sticky: Heading 4 "Template Settings"
Input Template Name*, Input Subject Line, Input Preview Text, Select Category
Toggle Active
Variables: chevron-toggle header + Badge count
[showVariables] chips (click = insert into HTML, hover x = remove), Button ghost "+ Add Variable" (window.prompt)
center col-span-5 Card glass overflow-hidden: header (Code icon + "HTML Editor"), Monaco Editor html/vs-dark
right col-span-4 Card glass overflow-hidden: header (Eye icon + "Live Preview"), iframe sandbox="allow-same-origin"
CardContent border-t: "Send Test Email" Input email + Button outline (Spinner sm | Send icon; disabled sendingTest || isNewTemplate)
[isNewTemplate] Text muted xs "Save the template first to send test emails" The rendering is the live rebuild email-template-editor/demo.tsx, built from @prima-modules/alchemy-ui and rendered by the docs host as one frame per state. The page's one read and three writes are props (mode, status, template, error, success, sendingTest, testEmail, showVariables); the tree is the frontmatter's. The console is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way identity-auth/ui/admin-users-console's demo imports it) with an admin viewer, since AdminRoute and AdminUser both require it, on /admin/communication/email/tmpl_1/edit, so "Operate" is the active rail entry (nav.tsx's admin domain lists /admin/communication among its paths, matched by prefix).
States (rendering.states, in order): loading (edit mode, the spinner inside the shell, before any row is known, no three-column layout at all); new-template (isNewTemplate true, the literal empty defaults, zero variable chips, the test-email button disabled with its own hint sentence, the preview showing the fixed placeholder paragraph since body_html is empty); populated (an existing template loaded, three variable chips that genuinely insert into the HTML textarea and update the live preview on click, the test-email row enabled); sending-test (the same populated template with sendingTest: true, the Send button disabled with the atom's own spinner in place of the icon, a typed address already in the box); error (edit mode, the load having failed: the red fallback sentence over the same empty defaults new-template shows, since a failed fetchTemplate never populates template; proving the two states share a render branch by construction, not by coincidence). Two real, distinct branches the source renders are not given their own state, both explained in the demo's header comment: the Save button's own busy state (saving, spinner + "Saving..."), the same spinner-in-button pattern sending-test already demonstrates on the neighbouring button; and the green success card, mutually exclusive with error in the source's own clearing logic, so never worth a whole extra frame to show a banner that is visually identical to the red one, just green.
Tokens and classes as the source uses them: Card glass for the header row, both alert cards and each of the three columns; !bg-red-50 !outline-red-200 / !bg-green-50 !outline-green-200 (the ! needed to beat the glass Card's own bg-white/80/outline-stone-300, reproduced verbatim, the source's actual Tailwind, not S1 tokens); border-line, text-primary, bg-primary/10 for the variable chips and the section dividers, S1 token classes throughout, unlike the raw-amber palette billing-credits/ui/subscription-manager's Gotchas flags for its own page. No dark: variant appears anywhere in this file (verified: zero matches at 62ba4fc), unlike every sibling recipe measured so far; a genuine difference worth recording rather than assuming the pattern repeats.
Responsive: grid-cols-12 with lg:col-span-3/5/4; below lg the three columns stack full-width in source order (settings, then the editor, then the preview) rather than reflowing side by side. Empty state: new-template is the nearest thing, the literal defaults with a placeholder preview. Error state: error, and, per the Application logic invariant above, the same empty-defaults render branch a template that is simply new but hasn't been touched yet would show, minus the red card. Loading state: the bare spinner inside the shell, edit mode's first fetch only; a refetch never happens on this page (Save does not reload, it only shows the success card).
What the demo deliberately does not render: window.prompt before addVariable (native, the button is inert instead, the same convention identity-auth/ui/admin-users-console's demo uses for its own prompt-gated actions); the window.location-free setTimeout navigate a successful create performs; the Save button's own busy state and the green success card (both explained above); Monaco's vs-dark editor itself, replaced by a plain textarea styled to match (dark background, monospace, no syntax highlighting) since @monaco-editor/react is not one of the four packages a demo may import from; the source's imperative doc.open()/write()/close() into a ref, replaced by React's own srcDoc, updated on every keystroke instead of through the source's 500ms debounce. All are documented in the demo's own header comment.
Security & tenancy
What the browser may hold: S2's two tokens only, never a payment or SMTP credential; the template row in memory (its HTML body included) is the admin's own draft and is not persisted client-side. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton sends the Bearer header, not a cookie, unless VITE_USE_COOKIE_AUTH is on, the same as every S2 page. Surface gating: email_templates_router.router is mounted under LABS_SURFACES with prefix /api/templates/emails (app_fastapi.py 875), so a prima_* session gets the surface-mismatch 403 before any handler runs, the same gate identity-auth/ui/admin-users-console's routes sit behind; every route on top of that additionally requires AdminUser (api/dependencies.py 568, get_current_admin_user), so a signed-in but non-admin tenant is refused by the dependency itself even if AdminRoute were bypassed client-side.
Tenancy here is platform-wide, not per-workspace: AdminUser alone, no workspace dependency and no workspace_id column on email_templates; a template exists once for the whole platform, not per tenant, unlike most of the rest of Labs; currentWorkspaceId is attached to the request by the client interceptor but read by none of these four routes. Hidden by CSS versus enforced: nothing client-side beyond AdminRoute's own redirect; the server-side AdminUser dependency is the only thing that actually refuses a non-admin. Attacker-controlled input the page sends: the raw body_html/body_text an admin types, stored and later substituted verbatim by render_template() (string .replace(), no escaping) into a real outbound email; a template author can put anything in a sent email's HTML, which is an accepted admin capability, not a bug, since only AdminUser reaches this page at all. What the page renders from the server unescaped: body_html itself, into the preview iframe via doc.write(); the sandbox="allow-same-origin" attribute (no allow-scripts) means an injected <script> tag does not execute in the preview, which is the one thing standing between an admin's own untrusted paste and a same-origin-capable iframe.
Tests
- Colocated with the demo:
email-template-editor/demo.test.tsx(vitest, jsdom,@testing-library/react), registeringafterEach(cleanup)for the same reasonidentity-auth/ui/admin-users-console's does (test-layout, the root config'sglobals: false):new-templaterenders zero variable chips and a disabled Send button;populatedrenders exactly three chips and an enabled Send button; clicking a chip inpopulatedappends its placeholder to the textarea's value and the same string appears in the iframe'ssrcDoc;sending-testrenders a disabled Send button and noSendicon;errorandnew-templaterender byte-identical settings-column markup (the shared-branch invariant above); no state's markup contains an access- or refresh-token shape. - The kit's render smoke:
node tooling/verify/render-smoke.mjs messaging-email-sms/ui/email-template-editorrenders the five states withrenderToStringandfetchstubbed (Acceptance checks). - Labs at 62ba4fc has no unit test of
AdminEmailTemplateEditor.tsx(grep -rl AdminEmailTemplateEditor frontend/src --include='*.test.*'is empty) and no Playwright spec for either/admin/communication/email/newor the edit route. - What a new app adds: a jsdom test with
apimocked that asserts the four catch blocks readdata.error ?? data.detail(once fixed) instead ofdata.detailalone; a test thathandleSendTestreadsresponse.data.statusand renders the warning sentence when it is"warning"rather than always the success one; a Playwright spec that creates a template, reloads on the resulting edit route, and asserts every field round-trips, which would catch the "no merge on refetch" behaviour step 3 documents today.
Deploy & ops
Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 136, lazy(() => import('./pages/AdminEmailTemplateEditor'))). Env baked at build: VITE_API_URL and VITE_USE_COOKIE_AUTH, S2's own; nothing Monaco-specific is configured in vite.config.ts, main.tsx or index.html at 62ba4fc, so @monaco-editor/react falls back to its default loader.config, which fetches the editor's worker bundle from a CDN (jsdelivr) at runtime rather than from the Vite bundle; a CSP that does not allow that origin breaks the HTML editor with no error this page surfaces, and it is the same reason the kit's demo cannot use the real Monaco component at all (Frontend). CSP and CORS: nothing of its own beyond the Monaco CDN allowance above; the four routes answer on the API origin under the SPA's existing allowance, gated by LABS_SURFACES and AdminUser as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the email_templates table these four routes read and write belongs to messaging-email-sms/email-templates. Argus: a synthetic admin session calling GET /api/templates/emails returning 200 with a list is this page's nearest liveness signal; the page itself has no health endpoint beyond the top-level fallback sentence on a failed single-row load.
Gotchas
- 2026-09-12 (measured at 62ba4fc): all four catch blocks read
err.response?.data?.detail(122, 159, 187); the Labs envelope's key iserror, notdetail(error_schemas.py130 to 136, the same shapebilling-credits/ui/subscription-manager's andidentity-auth/ui/admin-users-console's Gotchas document). So a duplicate-name 409's "An email template with this name already exists.", a missing-row 404's "Email template not found.", andsend_test_email's own"Failed to send test email: {e}"all reach the screen as one of three fixed fallback sentences instead. Fix: the samedata.error ?? data.detail ?? data.messagehelper the sibling recipes propose. - 2026-09-12 (measured at 62ba4fc):
handleSendTestnever reads the response body ofPOST /templates/emails/test(176 to 191);send_test_emailreturns{"status": "warning", "message": "Email not sent (transport '…' not configured or error occurred; see server logs)"}on the same HTTP 200 a genuine send returns (email_service.py401 to 410), so an admin whose platform transport is unconfigured sees the identical "Test email sent to X" sentence a real delivery would produce, with no way to tell from this page alone whether the recipient actually received anything. - 2026-09-12 (measured at 62ba4fc):
AdminCommunication.tsx's SMS tab navigates to/admin/communication/sms/${id}and/admin/communication/sms/${id}/edit(lines 413, 442), and its own "Create" button navigates to/admin/communication/create?type=sms(line 400); none of the three paths has a matching<Route>inApp.tsxat 62ba4fc (grep -n "communication" frontend/src/App.tsxlists only the hub and the two email routes). Every SMS-side link on the hub page is dead today, independent of anything in this recipe. - 2026-09-12 (measured at 62ba4fc):
fetchTemplate's success handler (setTemplate(response.data), line 119) replaces the entire form, not just missing fields; there is no code path that ever refetches after the first load (Save does not reload), so this is only reachable on the very first mount, but a future feature that added a manual "Reload" button would silently discard any unsaved local edit with no warning. - 2026-09-12 (measured at 62ba4fc):
insertVariablealways appends to the end ofbody_html(line 197), with no read of the Monaco cursor's actual position; an admin inserting a variable into the middle of an existing template has to cut and paste it into place by hand afterward. - 2026-09-12 (measured at 62ba4fc): no
dark:class appears anywhere in this file (grep -c "dark:" AdminEmailTemplateEditor.tsxis 0), unlike every sibling frontend recipe measured in this kit so far, each of which documentsdark:classes with no toggle anywhere in Labs; recorded here as a genuine difference, not an oversight in this recipe. - 2026-09-12 (kit):
messaging-email-sms's three sibling backend recipes (email-transport,email-branding,workspace-smtp-relay) were authored in the same wave as this recipe and already citemessaging-email-sms/ui/email-template-editorin their ownfrontend_modules, confirmed by reading all three files before this recipe was written; the id above was kept to match on the first attempt, avoiding the doubled-id collisionbilling-credits/ui/subscription-manager's Gotchas records from an earlier wave.
Reference implementation
Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:
frontend/src/pages/AdminEmailTemplateEditor.tsx(460 lines): imports andEmailTemplate/CATEGORY_OPTIONS(1 to 54), the component and its state (56 to 83), the preview-debounce and iframe-write effects (86 to 113),fetchTemplate(115 to 126),handleSave(128 to 163),handleSendTest(165 to 191),insertVariable/addVariable/removeVariable(193 to 217), the loading return (219 to 230), the header (232 to 256), the alert cards (258 to 275), the settings sidebar (280 to 373), the Monaco editor (376 to 401), the preview and test-email panel (404 to 443), thedebounce()utility (452 to 458).frontend/src/components/AdminNavigation.tsx(23 lines): the delegator toAppShell, C3.frontend/src/pages/AdminCommunication.tsx: the hub this page is opened from (207, 285, 298, 351, 400, 413, 442 for the navigation targets named in "When to use" and Gotchas).frontend/src/App.tsx: the lazy import (136), the two routes (852 to 866).frontend/src/services/api.ts: the singleton and its interceptors, unchanged from S2.backend/api/routers/email_templates_router.py(89 lines): all six routes,AdminUseron every one.backend/services/email_service.py:get_template/list_templates(221 to 234),create_template(302 to 313),update_template(316 to 330),send_test_email(374 to 410),render_template(235 to 253).backend/schemas/communication_schemas.py11 to 35:EmailTemplateBase/Create/Update/Response.backend/models/client_onboarding_model.py246 to 260: theemail_templatestable.backend/core/enums.py134 to 140:EmailCategory.backend/schemas/error_schemas.py112 to 136:create_error_response, the envelope this page misreads.backend/app_fastapi.py875: the router'sLABS_SURFACESmount.- The demo:
modules/messaging-email-sms/ui/email-template-editor/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.tsx.
Deviations of the reference from this recipe, all dated 2026-09-12: the detail reads; the unread test-send response body; the three dead SMS-side routes on the hub page; the no-merge refetch; the cursor-blind variable insert; no dark: classes anywhere in this file, unlike its siblings.
Acceptance checks
From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, an admin session), or from a new app built to this recipe:
grep -c "data?.detail" src/pages/AdminEmailTemplateEditor.tsxprints3at 62ba4fc (load, save, test) and0after the fix.grep -n "api\.\(get\|post\|put\)(" src/pages/AdminEmailTemplateEditor.tsxprints exactly four lines naming/api/templates/emails/${templateId},/api/templates/emails(twice, the POST and the PUT template literal),/api/templates/emails/test.- Browser walk-through, signed in as an admin: (a) open
/admin/communication/email/new, observe the empty three-column form and a disabled Send button with its hint sentence; (b) type an HTML body and click a would-be variable chip once the template has been saved once and reloaded, observe the placeholder land at the end of the textarea and the preview iframe update; (c) with the platform transport unset server-side, click Send on a saved template, observe the same "Test email sent to X" sentence a working transport would produce, and confirm in the network panel that the response body'sstatuswas"warning", the gap this recipe's Gotchas names; (d) reload the edit route for a template with unsaved local changes still in the tab, observe every field reset to the last-saved row with no warning. - Demo gate:
node tooling/verify/render-smoke.mjs messaging-email-sms/ui/email-template-editorrenders all five states withrenderToStringandfetchstubbed, 0 failures, 2026-09-12;npm run checkpasses: the frontmatter shape, the internal-term scan, and the states list matching the demo's exported keys. - Verifier:
node tooling/verify/compare.mjs messaging-email-sms/ui/email-template-editor --state new-template --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(an admin session, route/admin/communication/email/new) writes a report withpage_errors: [];mismatch_ratiois read for the header and the settings sidebar only, since the app's Monaco editor and the demo's textarea are never going to match pixel-for-pixel (excluded by name: the HTML editor pane).
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.