diff --git a/packages/iios-service/package.json b/packages/iios-service/package.json index 30298be..1113aec 100644 --- a/packages/iios-service/package.json +++ b/packages/iios-service/package.json @@ -24,6 +24,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "dotenv": "^16.4.7", + "handlebars": "^4.7.9", "ioredis": "^5.11.1", "jsonwebtoken": "^9.0.3", "jwks-rsa": "^4.1.0", diff --git a/packages/iios-service/src/templates/template.renderer.spec.ts b/packages/iios-service/src/templates/template.renderer.spec.ts new file mode 100644 index 0000000..c8f29e3 --- /dev/null +++ b/packages/iios-service/src/templates/template.renderer.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { renderTemplate, MissingTemplateVariableError } from './template.renderer'; + +describe('renderTemplate (pure)', () => { + it('substitutes variables into subject / html / text', () => { + const out = renderTemplate( + { subject: 'Receipt for {{firstName}}', bodyHtml: '

Hi {{firstName}}

', bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, + { firstName: 'Dana' }, + ); + expect(out.subject).toBe('Receipt for Dana'); + expect(out.html).toBe('

Hi Dana

'); + expect(out.text).toBe('Hi Dana'); + }); + + // The load-bearing safety property: a customer-supplied name goes into an HTML email body. + it('escapes HTML in the html body, but leaves subject/text verbatim (XSS)', () => { + const out = renderTemplate( + { subject: 'Hi {{firstName}}', bodyHtml: '

{{firstName}}

', bodyText: '{{firstName}}', variables: ['firstName'] }, + { firstName: '' }, + ); + expect(out.html).toBe('

<script>alert(1)</script>

'); + expect(out.html).not.toContain(''); // plaintext is not HTML → not escaped + }); + + it('supports {{#if}} and {{#each}}', () => { + const out = renderTemplate( + { bodyText: '{{#if companyName}}Company: {{companyName}}\n{{/if}}{{#each items}}- {{this}}\n{{/each}}', variables: [] }, + { companyName: 'Acme', items: ['a', 'b'] }, + ); + expect(out.text).toBe('Company: Acme\n- a\n- b\n'); + }); + + it('throws when a declared variable is missing (fail loud)', () => { + expect(() => renderTemplate({ bodyText: 'Hi {{firstName}}', variables: ['firstName'] }, {})).toThrow(MissingTemplateVariableError); + }); + + it('renders only the parts that are present', () => { + const out = renderTemplate({ bodyText: 'code {{code}}', variables: ['code'] }, { code: '123' }); + expect(out.text).toBe('code 123'); + expect(out.subject).toBeUndefined(); + expect(out.html).toBeUndefined(); + }); +}); diff --git a/packages/iios-service/src/templates/template.renderer.ts b/packages/iios-service/src/templates/template.renderer.ts new file mode 100644 index 0000000..5668f59 --- /dev/null +++ b/packages/iios-service/src/templates/template.renderer.ts @@ -0,0 +1,46 @@ +import Handlebars from 'handlebars'; + +/** The raw, unrendered strings of a template (from the DB row or an inline source). */ +export interface RawTemplate { + subject?: string | null; + bodyHtml?: string | null; + bodyText?: string | null; + /** Declared variable names. Every one MUST be supplied at render time (fail loud). */ + variables?: string[] | null; +} + +export interface RenderedContent { + subject?: string; + html?: string; + text?: string; +} + +/** A declared variable was not supplied — we refuse to send a half-rendered "Hi ," message. */ +export class MissingTemplateVariableError extends Error { + constructor(public readonly missing: string[]) { + super(`missing required template variable(s): ${missing.join(', ')}`); + this.name = 'MissingTemplateVariableError'; + } +} + +/** + * Pure render: interpolate `vars` into a template's strings. HTML output is auto-escaped (values + * like a customer name go into an email body — XSS risk); subject and plaintext are NOT escaped. + * No I/O — the caller supplies the raw template. Throws if any declared variable is absent. + */ +export function renderTemplate(tpl: RawTemplate, vars: Record): RenderedContent { + const declared = tpl.variables ?? []; + const missing = declared.filter((name) => vars[name] === undefined || vars[name] === null); + if (missing.length > 0) throw new MissingTemplateVariableError(missing); + + const out: RenderedContent = {}; + if (tpl.subject != null) out.subject = compile(tpl.subject, false)(vars); + if (tpl.bodyHtml != null) out.html = compile(tpl.bodyHtml, true)(vars); + if (tpl.bodyText != null) out.text = compile(tpl.bodyText, false)(vars); + return out; +} + +/** `escape=true` → HTML-escape interpolated values (for the html body); false → verbatim. */ +function compile(src: string, escape: boolean): Handlebars.TemplateDelegate { + return Handlebars.compile(src, { noEscape: !escape, strict: false }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5159988..32beda2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,6 +364,9 @@ importers: dotenv: specifier: ^16.4.7 version: 16.6.1 + handlebars: + specifier: ^4.7.9 + version: 4.7.9 ioredis: specifier: ^5.11.1 version: 5.11.1 @@ -2224,6 +2227,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -3052,6 +3060,11 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + uid2@1.0.0: resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} engines: {node: '>= 4.0.0'} @@ -3243,6 +3256,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -5013,6 +5029,15 @@ snapshots: graceful-fs@4.2.11: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5821,6 +5846,9 @@ snapshots: ufo@1.6.4: {} + uglify-js@3.19.3: + optional: true + uid2@1.0.0: {} uid@2.0.2: @@ -6008,6 +6036,8 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0