@arraypress/email-template-editor
v0.2.0
Published
DB-backed email template system with declared variables, content blocks, and a drop-in admin editor with live iframe preview. Pairs with @arraypress/email-templates for HTML rendering.
Maintainers
Readme
@arraypress/email-template-editor
DB-backed email template system with declared variables, content blocks, and a drop-in admin editor with live iframe preview. Pairs with @arraypress/email-templates (HTML scaffolds + components).
Three layers, importable independently:
| Sub-export | What | Imported by |
|---|---|---|
| @arraypress/email-template-editor | Registry + render orchestration + 8 canonical content blocks | Worker / backend |
| @arraypress/email-template-editor/store | Kysely-shaped CRUD helpers over an email_templates table | Worker / backend |
| @arraypress/email-template-editor/react | <EmailTemplateEditor> admin component | Admin UI |
Why this exists
Hand-rolling an email template surface means re-deriving the same five primitives every time: storage CRUD, {placeholder} substitution, structured-block rendering (download lists, order summaries), preview generation, and an editor UI with variable-pickers + iframe preview. This package collapses that into ~30 lines of host glue.
The interesting design choice: templates declare their valid variables and content blocks upfront in a registry. The renderer validates against the declaration. The editor uses it to render an "available placeholders" chip-row so admins can't reference variables the sender never passes (a classic footgun of free-text template systems).
Install
npm install @arraypress/email-template-editorQuick start — backend
import {
createTemplateRegistry,
renderEmail,
} from '@arraypress/email-template-editor';
const registry = createTemplateRegistry({
customer_magic_link: {
name: 'Customer magic link',
description: 'Sent when a customer requests login by email.',
variables: {
customer_name: { sample: 'Jane Smith' },
magic_url: { sample: '#', required: true },
},
},
order_receipt: {
name: 'Order receipt',
variables: {
customer_name: { sample: 'Jane' },
order_id: { sample: 'ORD-12345' },
},
blocks: ['order_summary', 'download_list'],
},
});
// In your sender:
import { getEmailTemplate } from '@arraypress/email-template-editor/store';
const row = await getEmailTemplate(db, 'order_receipt');
const { subject, html, text } = renderEmail({
registry,
templateKey: 'order_receipt',
template: row,
vars: { customer_name: 'Jane', order_id: 'ORD-1' },
blocks: {
order_summary: { 'Order': 'ORD-1', 'Total': '$10' },
download_list: { products: [{ name: 'Theme', files: [{ name: 'theme.zip', url: '#' }] }] },
},
brandColor: '#06d6a0',
storeName: 'Acme',
buttonUrl: 'https://acme.com/orders/ORD-1',
});
await emailClient.send({ to: customerEmail, subject, html, text });Quick start — admin UI
import { EmailTemplateEditor } from '@arraypress/email-template-editor/react';
import { registry } from './lib/email-registry'; // your createTemplateRegistry call
import { api } from './lib/api';
import { toast } from 'sonner';
export function EmailTemplatesSettings() {
return (
<EmailTemplateEditor
registry={registry}
api={{
list: () => api.emailTemplates.list(),
update: (key, updates) => api.emailTemplates.update(key, updates),
renderPreview: (key, draft) => api.emailTemplates.renderPreview(key, draft),
}}
onToast={(e) => toast[e.kind](e.message)}
/>
);
}The component handles:
- Fetching the template list on mount
- Master-detail layout (list left, editor right)
- Variable + block chip-row showing only the placeholders the active template declared (click to insert at cursor)
- Debounced preview re-renders into a sandboxed iframe
- Save button with dirty-state detection
Canonical content blocks
Eight built-in blocks shipped via ContentBlockSchemas:
| Block | Shape |
|---|---|
| download_list | Product cards with files + license keys |
| license_keys | Standalone license-key chips |
| order_summary | Key-value table (Order, Total, Payment) |
| tracking_info | Carrier + tracking number card |
| refund_items | Refund line-item table with adjustments + total |
| gift_card | Gift card code in a styled box |
| contact_details | Contact form submission as key-value |
| key_value | Generic key/value table (escape hatch) |
Need a custom block? Register one:
registry.registerBlock('subscription_renewal', {
sample: { plan: 'Pro', amount: '$29', next_charge: 'May 1, 2026' },
render: (data) => `
<div style="border:1px solid #eee;padding:16px;border-radius:8px;">
<strong>${data.plan}</strong> renews ${data.next_charge} for ${data.amount}
</div>
`,
});The editor automatically picks it up — chip-row, preview, validation all work without further code.
DB schema
The /store sub-export expects an email_templates table with this shape (column names are required for the canonical helpers; pass { tableName } to override the table name):
CREATE TABLE email_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
heading TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
button_text TEXT,
footer_text TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);Use seedEmailTemplate(db, row) in your migration / setup script to populate rows for each registered template.
Validation
renderEmail throws if a required: true variable is missing from vars. Use try/catch in your sender or pre-validate at the call site. Optional vars collapse to empty string silently.
Block placeholders for blocks the template doesn't declare are left as-is (treated as simple-var placeholders). Block placeholders for blocks the template DOES declare but where no data was passed render as empty (drop the placeholder rather than leak {block_name} into the email body).
Tests
npm testPure-function backend covered by 38 Node tests (registry, render orchestration, content blocks, placeholder replacement). React component tested manually — automated React tests intentionally not in v0.1 to keep deps minimal.
License
MIT
