diff --git a/tools/v2/individual/email-template-library/docs/contract.md b/tools/v2/individual/email-template-library/docs/contract.md new file mode 100644 index 00000000..af1f5dcb --- /dev/null +++ b/tools/v2/individual/email-template-library/docs/contract.md @@ -0,0 +1,104 @@ +# Email Template Library Core Contract + +This document describes the folder-local core service for future hook and UI +work. It does not authorize any main app integration. + +## Public API + +Import from `tools/v2/individual/email-template-library/index.mjs`. + +```js +import { createTemplateService } from "./index.mjs"; +``` + +## Template Shape + +```js +{ + id: "tmpl-follow-up", + name: "Polite follow-up", + categoryId: "cat-follow-up", + subject: "Following up on {{topic}}", + body: "Hi {{firstName}}, I wanted to follow up on {{topic}}.", + variables: [ + { key: "firstName", label: "First name" }, + { key: "topic", label: "Topic" } + ], + tags: ["follow-up", "customer"], + updatedAt: "2026-06-02" +} +``` + +Rules: + +- IDs use only letters, numbers, `_`, and `-`. +- `subject` and `body` are required. +- Variables use `{{variableKey}}` placeholders. +- Every placeholder must be declared in `variables`. +- Rendering substitutes only declared variables and reports missing values. + +## Service Methods + +`createTemplateService({ templates, categories })` returns: + +- `getTemplates()` +- `getCategories()` +- `getTemplate(id)` +- `saveTemplate(template)` +- `deleteTemplate(id)` +- `searchTemplates({ query, categoryId, limit })` +- `renderTemplate(id, values)` + +The service stores data in local memory only and returns cloned values to avoid +accidental fixture mutation. + +## State Shapes + +`createTemplateListState` and `searchTemplates` return: + +```js +{ + status: "success" | "empty" | "error", + isLoading: false, + error: null, + query: "invoice", + templates: [], + totalCount: 0 +} +``` + +`createLoadingState(query)` returns: + +```js +{ + status: "loading", + isLoading: true, + error: null, + query: "invoice", + templates: [], + totalCount: 0 +} +``` + +## Error State + +Validation errors use `EmailTemplateLibraryError` and include a field when the +error is field-specific: + +```js +{ + status: "error", + error: { + message: "template subject is required", + field: "subject" + } +} +``` + +## Known Limitations + +- Data is in-memory only. +- No template persistence, user account sync, analytics, or remote search exists. +- No live network calls or production mail content are introduced. +- Hooks and UI components are future issues. +- Main app compose integration is out of scope for this core issue. diff --git a/tools/v2/individual/email-template-library/fixtures/template-core-fixtures.json b/tools/v2/individual/email-template-library/fixtures/template-core-fixtures.json new file mode 100644 index 00000000..f7e19fa1 --- /dev/null +++ b/tools/v2/individual/email-template-library/fixtures/template-core-fixtures.json @@ -0,0 +1,111 @@ +{ + "metadata": { + "tool": "email-template-library", + "fixtureVersion": 1, + "scope": "folder-local", + "dataPolicy": [ + "Synthetic email templates only.", + "No real recipients, credentials, wallet values, or production mail content.", + "No live network references are required to run tests." + ] + }, + "categories": [ + { + "id": "cat-follow-up", + "name": "Follow-up" + }, + { + "id": "cat-billing", + "name": "Billing" + }, + { + "id": "cat-support", + "name": "Support" + } + ], + "templates": [ + { + "id": "tmpl-follow-up", + "name": "Polite follow-up", + "categoryId": "cat-follow-up", + "subject": "Following up on {{topic}}", + "body": "Hi {{firstName}}, I wanted to follow up on {{topic}}. Please let me know if you have any questions.", + "variables": [ + { + "key": "firstName", + "label": "First name" + }, + { + "key": "topic", + "label": "Topic" + } + ], + "tags": ["follow-up", "customer", "polite"], + "updatedAt": "2026-06-02" + }, + { + "id": "tmpl-invoice-copy", + "name": "Invoice copy request", + "categoryId": "cat-billing", + "subject": "Invoice copy for {{invoiceMonth}}", + "body": "Hi {{firstName}}, I attached the invoice copy for {{invoiceMonth}}. Reply here if anything looks off.", + "variables": [ + { + "key": "firstName", + "label": "First name" + }, + { + "key": "invoiceMonth", + "label": "Invoice month" + } + ], + "tags": ["invoice", "billing", "receipt"], + "updatedAt": "2026-06-04" + }, + { + "id": "tmpl-support-resolution", + "name": "Support resolution", + "categoryId": "cat-support", + "subject": "Resolution for {{caseId}}", + "body": "Hi {{firstName}}, we resolved case {{caseId}}. The fix was applied and no further action is needed.", + "variables": [ + { + "key": "firstName", + "label": "First name" + }, + { + "key": "caseId", + "label": "Case ID" + } + ], + "tags": ["support", "case", "resolution"], + "updatedAt": "2026-06-06" + } + ], + "searchCases": [ + { + "id": "search-billing", + "input": { + "query": "invoice receipt", + "limit": 2 + }, + "expectedFirstId": "tmpl-invoice-copy" + }, + { + "id": "search-follow-up", + "input": { + "query": "polite customer follow up", + "limit": 2 + }, + "expectedFirstId": "tmpl-follow-up" + }, + { + "id": "search-empty", + "input": { + "query": "banana telescope", + "limit": 2 + }, + "expectedFirstId": null + } + ] +} diff --git a/tools/v2/individual/email-template-library/index.mjs b/tools/v2/individual/email-template-library/index.mjs new file mode 100644 index 00000000..e27fd6f1 --- /dev/null +++ b/tools/v2/individual/email-template-library/index.mjs @@ -0,0 +1,16 @@ +export { + EmailTemplateLibraryError, + LIMITS, + sanitizeText, + extractTemplateVariables, + validateTemplateVariable, + validateTemplate, + validateCategory, + renderTemplateContent, + scoreTemplate, + createTemplateListState, + createLoadingState, + createEmptyState, + createErrorState, + createTemplateService, +} from "./services/template-service.mjs"; diff --git a/tools/v2/individual/email-template-library/services/template-service.mjs b/tools/v2/individual/email-template-library/services/template-service.mjs new file mode 100644 index 00000000..0d7c09c4 --- /dev/null +++ b/tools/v2/individual/email-template-library/services/template-service.mjs @@ -0,0 +1,353 @@ +/** + * Email Template Library - Core Service + * + * Folder-local, dependency-free template CRUD, search, and rendering logic. + * No main app integration, persistence, network calls, or production data. + */ + +export class EmailTemplateLibraryError extends Error { + constructor(message, field = null) { + super(message); + this.name = "EmailTemplateLibraryError"; + this.field = field; + } +} + +export const LIMITS = Object.freeze({ + MAX_ID_LENGTH: 100, + MAX_NAME_LENGTH: 120, + MAX_SUBJECT_LENGTH: 300, + MAX_BODY_LENGTH: 8000, + MAX_VARIABLES: 30, + MAX_TAGS: 16, + MAX_TAG_LENGTH: 64, + MAX_TEMPLATES: 200, + DEFAULT_LIMIT: 20, + MAX_LIMIT: 50, +}); + +const ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const VARIABLE_PATTERN = /\{\{\s*([a-zA-Z][a-zA-Z0-9_]*)\s*\}\}/g; +const STOP_WORDS = new Set(["a", "an", "and", "for", "in", "is", "of", "on", "or", "the", "to"]); + +export function sanitizeText(value, maxLength = LIMITS.MAX_BODY_LENGTH) { + if (typeof value !== "string") return ""; + const sanitized = value.replace(/[\x00-\x1F\x7F]/g, " ").replace(/\s+/g, " ").trim(); + return sanitized.length > maxLength ? sanitized.slice(0, maxLength).trim() : sanitized; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function validateId(value, field) { + const id = sanitizeText(value, LIMITS.MAX_ID_LENGTH); + if (!id) throw new EmailTemplateLibraryError(`${field} is required`, field); + if (!ID_PATTERN.test(id)) { + throw new EmailTemplateLibraryError(`${field} contains invalid characters`, field); + } + return id; +} + +function tokenize(value) { + return [ + ...new Set( + sanitizeText(value) + .toLowerCase() + .split(/[^a-z0-9]+/g) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)), + ), + ]; +} + +export function extractTemplateVariables(subject = "", body = "") { + const combined = `${subject} ${body}`; + const keys = new Set(); + let match; + while ((match = VARIABLE_PATTERN.exec(combined)) !== null) { + keys.add(match[1]); + } + return [...keys].sort(); +} + +export function validateTemplateVariable(variable) { + if (variable === null || typeof variable !== "object" || Array.isArray(variable)) { + throw new EmailTemplateLibraryError("variable must be a plain object", "variables"); + } + const key = validateId(variable.key, "variable.key"); + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { + throw new EmailTemplateLibraryError("variable key must start with a letter", "variable.key"); + } + const label = sanitizeText(variable.label, LIMITS.MAX_NAME_LENGTH); + if (!label) throw new EmailTemplateLibraryError("variable label is required", "variable.label"); + return { key, label }; +} + +function validateTags(tags) { + if (tags === undefined || tags === null) return []; + if (!Array.isArray(tags)) { + throw new EmailTemplateLibraryError("tags must be an array", "tags"); + } + if (tags.length > LIMITS.MAX_TAGS) { + throw new EmailTemplateLibraryError(`tags exceeds ${LIMITS.MAX_TAGS} items`, "tags"); + } + return tags.map((tag, index) => { + const sanitized = sanitizeText(tag, LIMITS.MAX_TAG_LENGTH).toLowerCase(); + if (!sanitized) throw new EmailTemplateLibraryError(`tags[${index}] is required`, "tags"); + return sanitized; + }); +} + +export function validateCategory(category) { + if (category === null || typeof category !== "object" || Array.isArray(category)) { + throw new EmailTemplateLibraryError("category must be a plain object", "category"); + } + const id = validateId(category.id, "category.id"); + const name = sanitizeText(category.name, LIMITS.MAX_NAME_LENGTH); + if (!name) throw new EmailTemplateLibraryError("category name is required", "category.name"); + return { id, name }; +} + +export function validateTemplate(template) { + if (template === null || typeof template !== "object" || Array.isArray(template)) { + throw new EmailTemplateLibraryError("template must be a plain object", "template"); + } + const id = validateId(template.id, "template.id"); + const name = sanitizeText(template.name, LIMITS.MAX_NAME_LENGTH); + if (!name) throw new EmailTemplateLibraryError("template name is required", "template.name"); + const categoryId = + template.categoryId === undefined || template.categoryId === null || template.categoryId === "" + ? null + : validateId(template.categoryId, "template.categoryId"); + const subject = sanitizeText(template.subject, LIMITS.MAX_SUBJECT_LENGTH); + const body = sanitizeText(template.body, LIMITS.MAX_BODY_LENGTH); + if (!subject) throw new EmailTemplateLibraryError("template subject is required", "subject"); + if (!body) throw new EmailTemplateLibraryError("template body is required", "body"); + + const variables = Array.isArray(template.variables) ? template.variables : []; + if (variables.length > LIMITS.MAX_VARIABLES) { + throw new EmailTemplateLibraryError( + `variables exceeds ${LIMITS.MAX_VARIABLES} items`, + "variables", + ); + } + const validatedVariables = variables.map(validateTemplateVariable); + const declared = new Set(validatedVariables.map((variable) => variable.key)); + const placeholders = extractTemplateVariables(subject, body); + for (const placeholder of placeholders) { + if (!declared.has(placeholder)) { + throw new EmailTemplateLibraryError( + `placeholder "${placeholder}" is not declared in variables`, + "variables", + ); + } + } + + return { + id, + name, + categoryId, + subject, + body, + variables: validatedVariables, + tags: validateTags(template.tags), + updatedAt: sanitizeText(template.updatedAt, 40) || null, + }; +} + +export function renderTemplateContent(templateInput, values = {}) { + const template = validateTemplate(templateInput); + const safeValues = values && typeof values === "object" && !Array.isArray(values) ? values : {}; + const missingVariables = []; + + function replaceVariables(text) { + return text.replace(VARIABLE_PATTERN, (_match, key) => { + const value = safeValues[key]; + if (value === undefined || value === null || String(value).length === 0) { + if (!missingVariables.includes(key)) missingVariables.push(key); + return ""; + } + return sanitizeText(String(value), LIMITS.MAX_BODY_LENGTH); + }).replace(/\s+/g, " ").trim(); + } + + return { + subject: replaceVariables(template.subject), + body: replaceVariables(template.body), + missingVariables: missingVariables.sort(), + }; +} + +export function scoreTemplate(templateInput, query = "", categoryLookup = new Map()) { + const template = validateTemplate(templateInput); + const terms = tokenize(query); + if (terms.length === 0) return 0; + + const categoryName = template.categoryId ? categoryLookup.get(template.categoryId) ?? "" : ""; + const buckets = [ + { weight: 5, tokens: new Set(tokenize(template.name)) }, + { weight: 4, tokens: new Set(tokenize(template.subject)) }, + { weight: 3, tokens: new Set(template.tags.flatMap(tokenize)) }, + { weight: 2, tokens: new Set(tokenize(categoryName)) }, + { weight: 1, tokens: new Set(tokenize(template.body)) }, + ]; + + let score = 0; + for (const term of terms) { + for (const bucket of buckets) { + if (bucket.tokens.has(term)) score += bucket.weight; + } + } + return score; +} + +export function createLoadingState(query = "") { + return { + status: "loading", + isLoading: true, + error: null, + query: sanitizeText(query, LIMITS.MAX_SUBJECT_LENGTH), + templates: [], + totalCount: 0, + }; +} + +export function createEmptyState(query = "") { + return { + status: "empty", + isLoading: false, + error: null, + query: sanitizeText(query, LIMITS.MAX_SUBJECT_LENGTH), + templates: [], + totalCount: 0, + }; +} + +export function createErrorState(error, query = "") { + return { + status: "error", + isLoading: false, + error: { + message: error instanceof Error ? error.message : String(error), + field: error instanceof EmailTemplateLibraryError ? error.field : null, + }, + query: sanitizeText(query, LIMITS.MAX_SUBJECT_LENGTH), + templates: [], + totalCount: 0, + }; +} + +export function createTemplateListState(input = {}, templates = [], categories = []) { + try { + const query = sanitizeText(input.query, LIMITS.MAX_SUBJECT_LENGTH); + const categoryId = + input.categoryId === undefined || input.categoryId === null || input.categoryId === "" + ? null + : validateId(input.categoryId, "categoryId"); + const limit = + input.limit === undefined ? LIMITS.DEFAULT_LIMIT : Number.parseInt(String(input.limit), 10); + if (!Number.isInteger(limit) || limit < 1 || limit > LIMITS.MAX_LIMIT) { + throw new EmailTemplateLibraryError(`limit must be between 1 and ${LIMITS.MAX_LIMIT}`, "limit"); + } + + const validCategories = categories.map(validateCategory); + const categoryLookup = new Map(validCategories.map((category) => [category.id, category.name])); + let validTemplates = templates.map(validateTemplate); + + if (categoryId) validTemplates = validTemplates.filter((template) => template.categoryId === categoryId); + if (query) { + validTemplates = validTemplates + .map((template) => ({ + template, + score: scoreTemplate(template, query, categoryLookup), + })) + .filter((entry) => entry.score > 0) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.template.name.localeCompare(b.template.name); + }) + .map((entry) => entry.template); + } else { + validTemplates = validTemplates.sort((a, b) => a.name.localeCompare(b.name)); + } + + const sliced = validTemplates.slice(0, limit); + if (sliced.length === 0) { + return { ...createEmptyState(query), totalCount: validTemplates.length }; + } + + return { + status: "success", + isLoading: false, + error: null, + query, + templates: clone(sliced), + totalCount: validTemplates.length, + }; + } catch (error) { + return createErrorState(error, input?.query ?? ""); + } +} + +export function createTemplateService(initial = {}) { + let templates = (initial.templates ?? []).map(validateTemplate); + let categories = (initial.categories ?? []).map(validateCategory); + + if (templates.length > LIMITS.MAX_TEMPLATES) { + throw new EmailTemplateLibraryError( + `templates exceeds ${LIMITS.MAX_TEMPLATES} items`, + "templates", + ); + } + + function getCategoryIds() { + return new Set(categories.map((category) => category.id)); + } + + function ensureCategoryExists(template) { + if (template.categoryId && !getCategoryIds().has(template.categoryId)) { + throw new EmailTemplateLibraryError( + `unknown categoryId: ${template.categoryId}`, + "categoryId", + ); + } + } + + for (const template of templates) ensureCategoryExists(template); + + return { + getTemplates() { + return clone(templates); + }, + getCategories() { + return clone(categories); + }, + getTemplate(id) { + const safeId = validateId(id, "id"); + const template = templates.find((entry) => entry.id === safeId); + return template ? clone(template) : null; + }, + saveTemplate(templateInput) { + const template = validateTemplate(templateInput); + ensureCategoryExists(template); + const index = templates.findIndex((entry) => entry.id === template.id); + if (index === -1) templates = [...templates, template]; + else templates = [...templates.slice(0, index), template, ...templates.slice(index + 1)]; + return clone(template); + }, + deleteTemplate(id) { + const safeId = validateId(id, "id"); + const before = templates.length; + templates = templates.filter((template) => template.id !== safeId); + return before !== templates.length; + }, + searchTemplates(input = {}) { + return createTemplateListState(input, templates, categories); + }, + renderTemplate(id, values = {}) { + const template = this.getTemplate(id); + if (!template) throw new EmailTemplateLibraryError(`template not found: ${id}`, "id"); + return renderTemplateContent(template, values); + }, + }; +} diff --git a/tools/v2/individual/email-template-library/specs.md b/tools/v2/individual/email-template-library/specs.md index 36ef38b3..2afd3a3f 100644 --- a/tools/v2/individual/email-template-library/specs.md +++ b/tools/v2/individual/email-template-library/specs.md @@ -1,41 +1,64 @@ -# Email Template Library - -Personal template system. - -## Scope - -- Release tier: $(System.Collections.Hashtable.Tier.ToUpperInvariant()) -- Audience: $(System.Collections.Hashtable.Audience) -- Folder ownership: $dir/ +# Email Template Library Specs -This is a self-contained tooling workspace. Do not wire this tool into the main app, routing, inbox architecture, wallet core, Stellar core, or design system unless a future integration issue explicitly allows it. +## Purpose -Recommended internal structure: +Provide a folder-local V2 individual tool for storing, searching, rendering, and +reusing personal email templates. -- components/ -- services/ -- hooks/ -- ests/ -- docs/ - "@ | Set-Content -Path "tools/v2/individual/email-template-library/README.md" - @" +The core service must stay framework-free so future UI and hook issues can reuse +the behavior without linking this tool into the main application. -# Email Template Library Specs +## Scope -## Purpose +- Release tier: V2 +- Audience: individual +- Folder ownership: `tools/v2/individual/email-template-library/` -Personal template system. +This is a self-contained tooling workspace. Do not wire this tool into the main +app, routing, inbox architecture, wallet core, Stellar integration, database +schema, or design system unless a future integration issue explicitly allows it. -## Contributor boundary +## Contributor Boundary All work for this tool should stay in: -$dir/ +``` +tools/v2/individual/email-template-library/ +``` -## Required issue categories +## Required Issue Categories - Architecture - Feature - UI and accessibility - Security and performance - Testing and documentation + +## Internal Structure + +- `types/` for local TypeScript-style contracts. +- `services/` for validation, CRUD, search, rendering, and state helpers. +- `fixtures/` for deterministic synthetic templates and categories. +- `tests/` for folder-local executable checks. +- `docs/` for the core API contract and known limitations. +- `hooks/` and `components/` can be added by future UI issues. + +## Review Contract + +Current and future work should keep these behaviors verifiable without the main +application: + +- Templates and categories are validated before they enter service state. +- Variable placeholders use declared `{{variableKey}}` syntax. +- Rendering substitutes only declared variables and reports missing values. +- Search is deterministic across name, subject, body, category, and tags. +- State helpers expose loading, success, empty, and error states for future UI + work. +- Public API exports stay folder-local through `index.mjs`. + +## Out of Scope + +- Main app routes, compose integration, inbox wiring, or design-system changes. +- Database persistence, cookies, analytics, or remote sync. +- Live network calls, secrets, production data, wallet, Stellar, or payment + integrations. diff --git a/tools/v2/individual/email-template-library/tests/template-service.test.mjs b/tools/v2/individual/email-template-library/tests/template-service.test.mjs new file mode 100644 index 00000000..dfc3fe38 --- /dev/null +++ b/tools/v2/individual/email-template-library/tests/template-service.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + EmailTemplateLibraryError, + createEmptyState, + createErrorState, + createLoadingState, + createTemplateListState, + createTemplateService, + extractTemplateVariables, + renderTemplateContent, + scoreTemplate, + validateTemplate, + validateTemplateVariable, +} from "../index.mjs"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(__dir, "..", "fixtures", "template-core-fixtures.json"); + +async function loadFixtures() { + return JSON.parse(await readFile(fixturePath, "utf8")); +} + +test("fixture metadata describes synthetic folder-local data", async () => { + const fixture = await loadFixtures(); + + assert.equal(fixture.metadata.tool, "email-template-library"); + assert.equal(fixture.metadata.scope, "folder-local"); + assert.ok(fixture.metadata.dataPolicy.some((entry) => entry.includes("Synthetic"))); +}); + +test("extractTemplateVariables returns unique sorted placeholder keys", () => { + assert.deepEqual( + extractTemplateVariables("Hello {{ firstName }}", "Topic {{topic}} {{firstName}}"), + ["firstName", "topic"], + ); +}); + +test("validateTemplateVariable accepts valid variable definitions", () => { + assert.deepEqual(validateTemplateVariable({ key: "firstName", label: "First name" }), { + key: "firstName", + label: "First name", + }); +}); + +test("validateTemplate rejects undeclared placeholders", async () => { + const { templates } = await loadFixtures(); + + assert.throws( + () => + validateTemplate({ + ...templates[0], + body: "Hello {{missingKey}}", + }), + EmailTemplateLibraryError, + ); +}); + +test("renderTemplateContent substitutes values and reports missing variables", async () => { + const { templates } = await loadFixtures(); + const result = renderTemplateContent(templates[0], { firstName: "Ada" }); + + assert.equal(result.subject, "Following up on"); + assert.equal(result.body.includes("Hi Ada"), true); + assert.deepEqual(result.missingVariables, ["topic"]); +}); + +test("scoreTemplate ranks matching templates above unrelated templates", async () => { + const { templates, categories } = await loadFixtures(); + const categoryLookup = new Map(categories.map((category) => [category.id, category.name])); + + const billingScore = scoreTemplate(templates[1], "invoice receipt billing", categoryLookup); + const supportScore = scoreTemplate(templates[2], "invoice receipt billing", categoryLookup); + + assert.ok(billingScore > supportScore); +}); + +test("createTemplateListState returns success for matching search", async () => { + const { templates, categories } = await loadFixtures(); + const state = createTemplateListState({ query: "invoice receipt" }, templates, categories); + + assert.equal(state.status, "success"); + assert.equal(state.templates[0].id, "tmpl-invoice-copy"); +}); + +test("createTemplateListState returns empty for unmatched search", async () => { + const { templates, categories } = await loadFixtures(); + const state = createTemplateListState({ query: "banana telescope" }, templates, categories); + + assert.equal(state.status, "empty"); + assert.deepEqual(state.templates, []); +}); + +test("fixture search cases return expected first result", async () => { + const { templates, categories, searchCases } = await loadFixtures(); + + for (const searchCase of searchCases) { + const state = createTemplateListState(searchCase.input, templates, categories); + const firstId = state.templates[0]?.id ?? null; + assert.equal(firstId, searchCase.expectedFirstId, searchCase.id); + } +}); + +test("service exposes CRUD and returns cloned templates", async () => { + const { templates, categories } = await loadFixtures(); + const service = createTemplateService({ templates, categories }); + + const original = service.getTemplate("tmpl-follow-up"); + original.name = "mutated"; + + assert.notEqual(service.getTemplate("tmpl-follow-up").name, "mutated"); + + service.saveTemplate({ + id: "tmpl-new", + name: "New template", + categoryId: "cat-support", + subject: "Case {{caseId}} update", + body: "Case {{caseId}} is still under review.", + variables: [{ key: "caseId", label: "Case ID" }], + tags: ["support"], + }); + + assert.equal(service.getTemplate("tmpl-new").name, "New template"); + assert.equal(service.deleteTemplate("tmpl-new"), true); + assert.equal(service.getTemplate("tmpl-new"), null); +}); + +test("service rejects unknown category ids on save", async () => { + const { templates, categories } = await loadFixtures(); + const service = createTemplateService({ templates, categories }); + + assert.throws( + () => + service.saveTemplate({ + id: "tmpl-bad", + name: "Bad category", + categoryId: "cat-missing", + subject: "Hello", + body: "Body", + variables: [], + }), + EmailTemplateLibraryError, + ); +}); + +test("service renders a template by id", async () => { + const { templates, categories } = await loadFixtures(); + const service = createTemplateService({ templates, categories }); + const result = service.renderTemplate("tmpl-invoice-copy", { + firstName: "Riley", + invoiceMonth: "June", + }); + + assert.equal(result.subject, "Invoice copy for June"); + assert.equal(result.body.includes("Hi Riley"), true); + assert.deepEqual(result.missingVariables, []); +}); + +test("state helpers expose loading, empty, and error contracts", () => { + assert.equal(createLoadingState("invoice").status, "loading"); + assert.equal(createLoadingState("invoice").isLoading, true); + assert.equal(createEmptyState("invoice").status, "empty"); + + const errorState = createErrorState(new EmailTemplateLibraryError("bad", "template.id"), "x"); + assert.equal(errorState.status, "error"); + assert.equal(errorState.error.field, "template.id"); +}); diff --git a/tools/v2/individual/email-template-library/types/index.d.ts b/tools/v2/individual/email-template-library/types/index.d.ts new file mode 100644 index 00000000..0bdd2e96 --- /dev/null +++ b/tools/v2/individual/email-template-library/types/index.d.ts @@ -0,0 +1,26 @@ +export interface TemplateVariable { + key: string; + label: string; +} + +export interface EmailTemplate { + id: string; + name: string; + categoryId: string | null; + subject: string; + body: string; + variables: TemplateVariable[]; + tags?: string[]; + updatedAt?: string | null; +} + +export interface TemplateCategory { + id: string; + name: string; +} + +export interface TemplateRenderResult { + subject: string; + body: string; + missingVariables: string[]; +}