diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index 1266e807f..ff280192e 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -11,6 +11,7 @@ import {EmailVerificationService} from '../services/EmailVerificationService.js' import {EventService} from '../services/EventService.js'; import {NotFound, ValidationError} from '../exceptions/index.js'; import {CatchAsync} from '../utils/asyncHandler.js'; +import {assertValidTemplateSyntax} from '../utils/templateValidation.js'; import {DASHBOARD_URI} from '../app/constants.js'; /** @@ -188,6 +189,10 @@ export class Actions { const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} = ActionSchemas.send.parse(req.body); + // Inline subject/body are templates too. Stored templates are checked when they're + // saved, but these arrive with the request and have never been validated. + assertValidTemplateSyntax({subject, body}); + // Normalize recipients to array and parse email/name type Recipient = {email: string; name?: string}; const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => { diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index b776ebe04..f740b8439 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -9,6 +9,7 @@ import {CampaignService} from '../services/CampaignService.js'; import {DomainService} from '../services/DomainService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; import {parseListSort} from '../utils/listSort.js'; +import {assertValidTemplateSyntax} from '../utils/templateValidation.js'; @Controller('campaigns') export class Campaigns { @@ -32,6 +33,8 @@ export class Campaigns { throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } + assertValidTemplateSyntax({subject, body}); + // Verify domain ownership and verification await DomainService.verifyEmailDomain(from, auth.projectId); @@ -164,6 +167,8 @@ export class Campaigns { throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } + assertValidTemplateSyntax({subject, body}); + // Verify domain ownership and verification if 'from' is being updated if (from) { await DomainService.verifyEmailDomain(from, auth.projectId); diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 20d83a0ea..da2c989c8 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -7,6 +7,7 @@ import {DomainService} from '../services/DomainService.js'; import {TemplateService} from '../services/TemplateService.js'; import {CatchAsync} from '../utils/asyncHandler.js'; import {parseListSort} from '../utils/listSort.js'; +import {assertValidTemplateSyntax} from '../utils/templateValidation.js'; @Controller('templates') export class Templates { @@ -84,6 +85,8 @@ export class Templates { return res.status(400).json({error: 'From address is required'}); } + assertValidTemplateSyntax({subject, body}); + // Verify domain ownership and verification await DomainService.verifyEmailDomain(from, auth.projectId!); @@ -117,6 +120,8 @@ export class Templates { return res.status(400).json({error: 'Template ID is required'}); } + assertValidTemplateSyntax({subject, body}); + // Verify domain ownership and verification if 'from' is being updated if (from) { await DomainService.verifyEmailDomain(from, auth.projectId!); diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index deb80c891..31591c771 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -1,5 +1,6 @@ import type {Campaign, Contact, Prisma} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, EmailSourceType, EmailStatus, TemplateType} from '@plunk/db'; +import {compileTemplate} from '@plunk/shared'; import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types'; import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import signale from 'signale'; @@ -537,6 +538,11 @@ export class CampaignService { // Get batch of recipients using cursor-based pagination const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor); + // Parse the Liquid templates once per batch rather than once per recipient. The + // subject and body are identical for every contact, only the variables differ. + const subjectTemplate = compileTemplate(campaign.subject); + const bodyTemplate = compileTemplate(campaign.body); + // Queue emails for each contact for (const contact of contacts) { try { @@ -553,17 +559,8 @@ export class CampaignService { manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`, }; - const renderedSubject = EmailService.format({ - subject: campaign.subject, - body: '', - data: variables, - }).subject; - - const renderedBody = EmailService.format({ - subject: '', - body: campaign.body, - data: variables, - }).body; + const renderedSubject = subjectTemplate.render(variables); + const renderedBody = bodyTemplate.render(variables); await EmailService.sendCampaignEmail({ projectId: campaign.projectId, diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 51ae7efa6..a6cc50670 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -642,8 +642,12 @@ export class EmailService { } /** - * Format email template by replacing variables in subject and body - * Uses shared template rendering from @plunk/shared + * Render a single email's subject and body. + * + * Uses the shared Liquid renderer from @plunk/shared, which caches parsed templates, + * so the per-email call sites here don't re-parse the same body for every recipient. + * When rendering a known template for many contacts in one pass, prefer + * `compileTemplate` to hoist the parse out of the loop entirely (see CampaignService). */ public static format({subject, body, data}: {subject: string; body: string; data: Record}): { subject: string; diff --git a/apps/api/src/services/__tests__/CampaignService.test.ts b/apps/api/src/services/__tests__/CampaignService.test.ts index e20e23ef4..32a09b512 100644 --- a/apps/api/src/services/__tests__/CampaignService.test.ts +++ b/apps/api/src/services/__tests__/CampaignService.test.ts @@ -665,4 +665,78 @@ describe('CampaignService', () => { expect(scheduledCampaign.totalRecipients).toBe(10); }); }); + + // ======================================== + // TEMPLATE RENDERING (processBatch) + // ======================================== + describe('processBatch template rendering', () => { + /** Create a SENDING campaign plus contacts, then run the first batch. */ + async function sendBatch({subject, body}: {subject: string; body: string}, contacts: Record[]) { + for (const data of contacts) { + await factories.createContact({projectId, subscribed: true, data}); + } + + const campaign = await factories.createCampaign({ + projectId, + subject, + body, + status: CampaignStatus.SENDING, + audienceType: CampaignAudienceType.ALL, + }); + + await CampaignService.processBatch(campaign.id, 1, 0, 500); + + return prisma.email.findMany({ + where: {campaignId: campaign.id}, + include: {contact: true}, + }); + } + + it('renders each contact through the Liquid template', async () => { + const emails = await sendBatch( + { + subject: '{% if locale == "es" %}Tu oferta{% else %}Your offer{% endif %}', + body: '

Hi {{firstName ?? there}}, you are on {{plan | upcase}}.

', + }, + [ + {firstName: 'Ada', plan: 'pro', locale: 'en'}, + {firstName: 'Bruno', plan: 'free', locale: 'es'}, + {plan: 'free', locale: 'en'}, + ], + ); + + expect(emails).toHaveLength(3); + + const byFirstName = new Map( + emails.map(email => [(email.contact.data as Record)?.firstName ?? null, email]), + ); + + expect(byFirstName.get('Ada')?.subject).toBe('Your offer'); + expect(byFirstName.get('Ada')?.body).toBe('

Hi Ada, you are on PRO.

'); + + expect(byFirstName.get('Bruno')?.subject).toBe('Tu oferta'); + expect(byFirstName.get('Bruno')?.body).toBe('

Hi Bruno, you are on FREE.

'); + + // Contact without a firstName falls back + expect(byFirstName.get(null)?.body).toBe('

Hi there, you are on FREE.

'); + }); + + it('renders loops over contact data', async () => { + const emails = await sendBatch( + { + subject: 'Your cart', + body: '
    {% for item in cart %}
  • {{item.name}}
  • {% endfor %}
', + }, + [{cart: [{name: 'Starter'}, {name: 'Team'}]}], + ); + + expect(emails[0]?.body).toBe('
  • Starter
  • Team
'); + }); + + it('still injects the per-recipient unsubscribe URL', async () => { + const emails = await sendBatch({subject: 'Hi', body: 'Unsubscribe'}, [{}]); + + expect(emails[0]?.body).toContain(`/unsubscribe/${emails[0]?.contactId}`); + }); + }); }); diff --git a/apps/api/src/services/__tests__/DomainService.test.ts b/apps/api/src/services/__tests__/DomainService.test.ts index 1d441fda3..5b9601554 100644 --- a/apps/api/src/services/__tests__/DomainService.test.ts +++ b/apps/api/src/services/__tests__/DomainService.test.ts @@ -18,6 +18,11 @@ describe('DomainService', () => { status: 'Success', tokens: ['token1', 'token2', 'token3'], }); + // DomainService swallows failures from these two, so an unmocked call is invisible + // in the results while still hitting AWS on every run — and for anyone whose .env + // holds real credentials it would mutate a live SES account. + vi.spyOn(SESService, 'deleteIdentity').mockResolvedValue(undefined); + vi.spyOn(SESService, 'disableFeedbackForwarding').mockResolvedValue(undefined); }); // ======================================== diff --git a/apps/api/src/utils/__tests__/templateValidation.test.ts b/apps/api/src/utils/__tests__/templateValidation.test.ts new file mode 100644 index 000000000..b20336405 --- /dev/null +++ b/apps/api/src/utils/__tests__/templateValidation.test.ts @@ -0,0 +1,64 @@ +import {describe, expect, it} from 'vitest'; + +import {HttpException} from '../../exceptions/index.js'; +import {assertValidTemplateSyntax} from '../templateValidation.js'; + +/** + * Rendering is intentionally forgiving — a broken template still sends, falling back to + * plain placeholder substitution. That makes write time the only place a syntax error + * can be reported, so this guard is what stops a broken `{% if %}` reaching an audience. + */ +describe('assertValidTemplateSyntax', () => { + it('accepts legacy placeholder syntax', () => { + expect(() => + assertValidTemplateSyntax({subject: 'Hi {{firstName ?? there}}', body: '

{{data.plan}}

'}), + ).not.toThrow(); + }); + + it('accepts Liquid conditionals, loops and filters', () => { + expect(() => + assertValidTemplateSyntax({ + subject: '{% if locale == "es" %}Hola{% else %}Hi{% endif %}', + body: '
    {% for item in cart %}
  • {{item.name | upcase}}
  • {% endfor %}
', + }), + ).not.toThrow(); + }); + + it('ignores fields that are absent or empty', () => { + expect(() => assertValidTemplateSyntax({subject: undefined, body: ''})).not.toThrow(); + expect(() => assertValidTemplateSyntax({body: null})).not.toThrow(); + }); + + it('rejects an unclosed tag with a 400 naming the field and position', () => { + let thrown: unknown; + try { + assertValidTemplateSyntax({body: '

ok

\n{% if plan == "pro" %}Pro'}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(HttpException); + const exception = thrown as HttpException; + expect(exception.code).toBe(400); + expect(exception.message).toContain('body'); + expect(exception.message).toContain('line 2'); + expect(exception.details).toMatchObject({field: 'body', line: 2}); + }); + + it('rejects an unclosed placeholder', () => { + expect(() => assertValidTemplateSyntax({subject: 'Hi {{firstName'})).toThrow(HttpException); + }); + + it('rejects a filter typo that rendering would silently drop', () => { + expect(() => assertValidTemplateSyntax({body: '{{firstName | upcse}}'})).toThrow(/upcse/); + }); + + it('rejects the file-system tags', () => { + expect(() => assertValidTemplateSyntax({body: "{% render 'secrets' %}"})).toThrow(/not available/); + expect(() => assertValidTemplateSyntax({body: "{% include 'secrets' %}"})).toThrow(/not available/); + }); + + it('reports the first offending field', () => { + expect(() => assertValidTemplateSyntax({subject: '{% if %}', body: 'fine'})).toThrow(/subject/); + }); +}); diff --git a/apps/api/src/utils/templateValidation.ts b/apps/api/src/utils/templateValidation.ts new file mode 100644 index 000000000..21b7c775c --- /dev/null +++ b/apps/api/src/utils/templateValidation.ts @@ -0,0 +1,32 @@ +import {validateTemplate} from '@plunk/shared'; + +import {ErrorCode, HttpException} from '../exceptions/index.js'; + +/** + * Reject template markup the Liquid engine cannot parse. + * + * Rendering is deliberately lenient — a template that fails to parse still sends, + * falling back to plain `{{variable}}` substitution — so authoring time is the only + * place a syntax error can be surfaced. Failing the write is what stops a broken + * `{% if %}` from reaching a whole audience. + */ +export function assertValidTemplateSyntax(fields: Record): void { + for (const [field, source] of Object.entries(fields)) { + if (typeof source !== 'string' || source.length === 0) { + continue; + } + + const result = validateTemplate(source); + + if (!result.valid) { + const position = result.line !== undefined ? ` (line ${result.line}, column ${result.column})` : ''; + + throw new HttpException( + 400, + `Invalid template syntax in ${field}${position}: ${result.error}`, + ErrorCode.VALIDATION_ERROR, + {field, line: result.line, column: result.column}, + ); + } + } +} diff --git a/apps/wiki/content/docs/concepts/templates.mdx b/apps/wiki/content/docs/concepts/templates.mdx index 427cab261..c09d19906 100644 --- a/apps/wiki/content/docs/concepts/templates.mdx +++ b/apps/wiki/content/docs/concepts/templates.mdx @@ -38,7 +38,17 @@ Templates can be created using the built-in editor or by uploading your own HTML ### Personalization -You can use contact data to personalize your templates by using the handlebars syntax `{{ key }}`, where `key` is the contact data key. +You can use contact data to personalize your templates by using the handlebars syntax `{{ key }}`, where `key` is the contact data key. Subjects and bodies are rendered with [Liquid](https://liquidjs.com/), so besides variables like `{{ key }}` you also get conditionals, loops and filters. See [Template language](/guides/template-language) for the full reference. + +```html +

Hi {{firstName ?? 'there'}},

+ +{% if locale == 'es' %} +

Tu plan {{plan | upcase}} se renueva pronto.

+{% else %} +

Your {{plan | upcase}} plan renews soon.

+{% endif %} +``` #### Always Available Variables @@ -60,7 +70,31 @@ You can provide fallback values for variables that might not be set: Hello {{firstName ?? 'there'}}! ``` -If `firstName` is not set, this will render as "Hello there!" +If `firstName` is not set, this will render as "Hello there!" Liquid's `default` filter does the same thing and can fall back to another variable rather than a literal: + +``` +Hello {{firstName | default: nickname}}! +``` + +#### Conditionals, loops and filters + +Anything Liquid supports works in a template — branch on a custom field, loop over an array, or transform a value with a filter: + +```html +{% case plan %} + {% when 'pro' %}

Here's 20% off your renewal.

+ {% when 'free' %}

Upgrade and save 20%.

+ {% else %}

Thanks for being with us.

+{% endcase %} + +
    + {% for item in cartItems %} +
  • {{item.name}} — {{item.price | times: quantity | round: 2}}
  • + {% endfor %} +
+``` + +This lets a single campaign cover combinations that would otherwise need one campaign per segment — e.g. three languages × two offers in one send instead of six. See [Template language](/guides/template-language). #### Special Fields @@ -93,6 +127,9 @@ There are three types of templates in Plunk. Each type is treated at the same pr ## What's next + + Conditionals, loops and filters with Liquid. + Send templated emails directly through `/v1/send`. diff --git a/apps/wiki/content/docs/guides/custom-fields.mdx b/apps/wiki/content/docs/guides/custom-fields.mdx index c996863c9..d7fdccb12 100644 --- a/apps/wiki/content/docs/guides/custom-fields.mdx +++ b/apps/wiki/content/docs/guides/custom-fields.mdx @@ -75,7 +75,7 @@ Plus these core contact fields, which exist outside `data`: `email`, `subscribed ### In templates -Reference any field by name as a Handlebars variable: +Reference any field by name as a template variable: ```html

Hi {{firstName ?? "there"}},

@@ -84,6 +84,16 @@ Reference any field by name as a Handlebars variable: The `?? fallback` syntax is Plunk-specific and lets you provide a default when the field is missing or null. Nested data (`data.profile.tier`) is accessible the same way: `{{profile.tier}}`. +Templates are rendered with Liquid, so custom fields can also drive conditionals, loops and filters: + +```html +{% if plan == 'pro' and lifetimeValue > 500 %} +

Your VIP renewal discount is ready.

+{% endif %} +``` + +Field names built from CSV headers often contain spaces (a "First Name" column becomes `first name`). Reference those directly — `{{first name}}` — or with a bracket lookup when combining with filters: `{{data["first name"] | capitalize}}`. See [Template language](/guides/template-language). + ### In segments Reference custom fields with the `data.` prefix in segment filters: diff --git a/apps/wiki/content/docs/guides/meta.json b/apps/wiki/content/docs/guides/meta.json index b15f1c28f..947bdf419 100644 --- a/apps/wiki/content/docs/guides/meta.json +++ b/apps/wiki/content/docs/guides/meta.json @@ -8,6 +8,7 @@ "idempotency", "localization", "webhooks", + "template-language", "importing-contacts", "custom-fields", "segment-filters", diff --git a/apps/wiki/content/docs/guides/template-language.mdx b/apps/wiki/content/docs/guides/template-language.mdx new file mode 100644 index 000000000..f55d2ac35 --- /dev/null +++ b/apps/wiki/content/docs/guides/template-language.mdx @@ -0,0 +1,190 @@ +--- +title: Template language +description: Use conditionals, loops and filters in subjects and bodies with Liquid +icon: Braces +--- + +Plunk renders email subjects and bodies with [Liquid](https://liquidjs.com/), the template language used by Shopify, Zendesk and Netlify. Every `{{variable}}` placeholder Plunk has always supported keeps working exactly as before — Liquid adds conditionals, loops and filters on top. + +The main thing this buys you is fewer campaigns. Instead of one campaign per segment, a single campaign can cover the whole combinatorial space: three languages × two offers is one send with branching copy rather than six sends to compare. + +## Variables + +Reference any contact field by name. Custom fields under `data` are available both with and without the prefix: + +```html +

Hi {{firstName}}, you are on the {{plan}} plan.

+

Same thing: {{data.firstName}} / {{data.plan}}

+``` + +These are always available regardless of contact data: + +| Variable | Description | +| -------------------- | -------------------------------------- | +| `{{id}}` | The contact's unique identifier | +| `{{email}}` | The contact's email address | +| `{{locale}}` | The contact's preferred locale | +| `{{subscribed}}` | Subscription status | +| `{{unsubscribeUrl}}` | URL to the unsubscribe page | +| `{{subscribeUrl}}` | URL to the subscribe/resubscribe page | +| `{{manageUrl}}` | URL to the preferences management page | + +A variable that isn't set renders as an empty string — it never errors and never leaks the placeholder into the email. + +### Fields whose names contain spaces + +CSV imports build field names from your column headers, so a "First Name" column becomes the field `first name`. Reference it directly, or with a bracket lookup if you want to combine it with filters: + +```html +

Hi {{first name}}

+

Hi {{data["first name"] | capitalize}}

+``` + +## Fallbacks + +`?? fallback` is Plunk-specific shorthand and takes a literal: + +```html +

Hi {{firstName ?? there}}, welcome back.

+``` + +Liquid's `default` filter is the general form and can fall back to another variable: + +```html +

Hi {{firstName | default: nickname}}

+``` + +Both treat a missing, `null`, `false` or empty value as "not set". + +## Conditionals + +`if` / `elsif` / `else`, `unless`, and `case` / `when` all work. This is the multilanguage pattern: + +```html +{% if locale == 'es' %} +

Hola {{firstName ?? cliente}}

+{% elsif locale == 'fr' %} +

Bonjour {{firstName ?? client}}

+{% else %} +

Hi {{firstName ?? there}}

+{% endif %} +``` + +And the segment-specific-offer pattern, without splitting the campaign: + +```html +{% case plan %} + {% when 'pro' %}

Here's 20% off your renewal.

+ {% when 'free' %}

Upgrade now and save 20%.

+ {% else %}

Thanks for being with us.

+{% endcase %} +``` + +Comparison (`==`, `!=`, `>`, `<`, `>=`, `<=`), boolean (`and`, `or`), `contains`, and `blank` / `empty` are all available: + +```html +{% if lifetimeValue > 500 and plan != 'free' %} +

You qualify for our VIP tier.

+{% endif %} +``` + + + Plunk uses JavaScript truthiness rather than Liquid's stricter rules, because imported + contact data very often has a column present but blank. An empty string is falsy, so + `{% if firstName %}` is false for a contact whose `firstName` is `""`. + + +## Loops + +Iterate arrays stored on a contact or passed to `/v1/send`: + +```html +
    + {% for item in cartItems %} +
  • {{forloop.index}}. {{item.name}} — {{item.price}}
  • + {% endfor %} +
+``` + +`forloop.index`, `.index0`, `.first`, `.last` and `.length` are available inside the loop, and `limit` / `offset` / `reversed` work on the `for` tag itself. + +Outputting an array directly — `{{items}}` — wraps each entry in an `
  • ` element. This predates Liquid support and is kept for compatibility; prefer an explicit `{% for %}` so you control the markup. + +## Filters + +Filters transform a value with `|`. The [full LiquidJS filter list](https://liquidjs.com/filters/overview.html) applies; these come up most: + +| Filter | Example | Result | +| ----------------------------------------- | --------------------------------------- | ------------- | +| `upcase` / `downcase` | `{{plan \| upcase}}` | `PRO` | +| `capitalize` | `{{firstName \| capitalize}}` | `Ada` | +| `default` | `{{firstName \| default: "there"}}` | `there` | +| `date` | `{{signupDate \| date: "%B %-d, %Y"}}` | `May 6, 2026` | +| `plus` / `minus` / `times` / `divided_by` | `{{price \| times: 0.8 \| round: 2}}` | `40` | +| `join` | `{{tags \| join: ", "}}` | `a, b` | +| `truncate` | `{{bio \| truncate: 40}}` | truncated | + +For `date` to work, store the value as a full ISO 8601 string (see [Custom fields](/guides/custom-fields)). + +## Computed values + +`assign` and `capture` let you derive a value once and reuse it — useful when an offer depends on something other than a raw field: + +```html +{% assign discount = lifetimeValue | divided_by: 20 %} +{% if discount > 25 %}{% assign discount = 25 %}{% endif %} + +

    Here's {{discount}}% off, calculated from your account history.

    +``` + +## Escaping template syntax + +To show `{{ }}` literally in an email, wrap it in `raw`: + +```html +{% raw %}Use {{firstName}} to personalise your own emails.{% endraw %} +``` + +## What isn't available + +`{% include %}`, `{% render %}` and `{% layout %}` are rejected. + +## Errors + +Saving a template or campaign whose syntax Liquid can't parse returns `400` with the line and column of the problem, so mistakes surface while you're editing. + +Rendering itself is deliberately forgiving — nothing about a template can fail a send: + +- An unknown variable renders empty. +- An unknown filter is skipped and the value passes through unchanged. +- If a stored template somehow can't be parsed at send time, Plunk falls back to plain `{{variable}}` substitution rather than dropping the email. + +## Notes for existing templates + +Templates written before Liquid keep rendering the same output in almost every case — plain placeholders, nested paths, `?? fallback`, missing variables and bare arrays are all unchanged. These are the cases that do render differently: + +**Fixes to previously wrong output** + +- A quoted fallback no longer leaks its quotes. `{{name ?? 'there'}}` used to render `'there'` — quotes included — and now renders `there`, which is what this guide always described. +- Falsy values render instead of disappearing. `0`, `false` and `NaN` used to come out as an empty string; `{{count}}` for a contact with `count: 0` now renders `0`, and `{{subscribed}}` renders `false`. Use `{% unless subscribed %}` to branch on it. +- A fallback containing `??` inside quotes is no longer truncated at the first `??`. +- Placeholders spanning multiple lines, and array indexes like `{{items.0}}`, now resolve; both used to render as literal text or empty. + +**Behaviour to check before upgrading** + +- Balanced tag markup in body copy is now executed. Prose like `Write {% if x %}…{% endif %} to branch` used to print verbatim and now evaluates to nothing. Wrap it in `{% raw %}` to keep it as text. Unbalanced markup — `Use {% if x %} in docs` — still fails to parse and survives untouched. +- A custom field whose name contains `|`, quotes or other operator characters is now parsed as an expression. Field names made of letters, digits, `_`, `-`, `.` and spaces are unaffected. + +## What's next + + + + Store the data your templates branch on. + + + How templates fit into campaigns, workflows and `/v1/send`. + + + Set a `locale` per contact to drive multilanguage templates. + + diff --git a/packages/shared/package.json b/packages/shared/package.json index 0979293c9..0ca9a745c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@plunk/types": "*", + "liquidjs": "^10.27.2", "zod": "^3.23.8" }, "exports": { diff --git a/packages/shared/src/__tests__/template.test.ts b/packages/shared/src/__tests__/template.test.ts new file mode 100644 index 000000000..8a5581337 --- /dev/null +++ b/packages/shared/src/__tests__/template.test.ts @@ -0,0 +1,402 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {clearTemplateCache, compileTemplate, renderTemplate, validateTemplate} from '../template/index.js'; + +/** + * The template engine is Liquid (see issue #426), replacing a regex-based + * `{{variable}}` substitution. Two things matter equally here: that the Liquid + * features campaigns need actually work, and that every template written against the + * old syntax keeps rendering byte for byte. + */ +describe('renderTemplate', () => { + beforeEach(() => { + clearTemplateCache(); + }); + + // ======================================== + // BACKWARDS COMPATIBILITY WITH {{variable}} + // ======================================== + describe('legacy placeholder syntax', () => { + it('substitutes a top-level variable', () => { + expect(renderTemplate('Hello {{name}}!', {name: 'World'})).toBe('Hello World!'); + }); + + it('substitutes a nested path', () => { + expect(renderTemplate('Hello {{data.firstName}}!', {data: {firstName: 'Ada'}})).toBe('Hello Ada!'); + }); + + it('resolves a key nested under data without the prefix', () => { + expect(renderTemplate('Hello {{firstName}}!', {data: {firstName: 'Ada'}})).toBe('Hello Ada!'); + }); + + it('prefers a top-level key over the same key under data', () => { + expect(renderTemplate('{{plan}}', {plan: 'pro', data: {plan: 'free'}})).toBe('pro'); + }); + + it('renders an unquoted ?? fallback when the value is missing', () => { + expect(renderTemplate('Hello {{name ?? Guest}}!', {})).toBe('Hello Guest!'); + }); + + it('renders a quoted ?? fallback when the value is missing', () => { + expect(renderTemplate("Hello {{firstName ?? 'there'}}!", {})).toBe('Hello there!'); + }); + + it('ignores the ?? fallback when the value is present', () => { + expect(renderTemplate('Hello {{firstName ?? there}}!', {firstName: 'Ada'})).toBe('Hello Ada!'); + }); + + it('treats the ?? fallback as a literal, not a variable reference', () => { + expect(renderTemplate('{{plan ?? free}}', {free: 'SHOULD NOT APPEAR'})).toBe('free'); + }); + + it('uses the first of several ?? fallbacks, as the old renderer did', () => { + // Fallbacks are literals, so the first one is always non-empty and wins. + expect(renderTemplate('{{a ?? b ?? c}}', {})).toBe('b'); + }); + + it('keeps a fallback containing spaces intact', () => { + expect(renderTemplate('{{title ?? Dear customer}}', {})).toBe('Dear customer'); + }); + + it('renders a missing variable as an empty string', () => { + expect(renderTemplate('Hello {{missing}}!', {})).toBe('Hello !'); + }); + + it('renders a missing nested path as an empty string', () => { + expect(renderTemplate('[{{a.b.c}}]', {a: {}})).toBe('[]'); + }); + + it('renders a bare array as HTML list items', () => { + expect(renderTemplate('{{items}}', {items: ['one', 'two']})).toBe('
  • one
  • \n
  • two
  • '); + }); + + it('resolves keys containing spaces, which CSV imports produce from headers', () => { + expect(renderTemplate('Hi {{first name}}!', {'first name': 'Ada'})).toBe('Hi Ada!'); + }); + + it('resolves a nested key containing spaces', () => { + expect(renderTemplate('{{profile.first name}}', {profile: {'first name': 'Ada'}})).toBe('Ada'); + }); + + it('renders an empty placeholder as an empty string', () => { + expect(renderTemplate('[{{}}][{{ }}]', {})).toBe('[][]'); + }); + + it('renders numbers and objects the way the old renderer did', () => { + expect(renderTemplate('{{count}}', {count: 42})).toBe('42'); + expect(renderTemplate('[{{o}}]', {o: {a: 1}})).toBe('[[object Object]]'); + }); + + it('leaves unrelated braces alone', () => { + expect(renderTemplate('', {})).toBe(''); + }); + + it('leaves a double brace it cannot resolve alone, as before', () => { + expect(renderTemplate('', {})).toBe(''); + }); + }); + + // ======================================== + // DELIBERATE DIVERGENCES FROM THE OLD RENDERER + // ======================================== + describe('intentional differences from the previous renderer', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + it('strips the quotes from a quoted ?? fallback', () => { + // The old renderer split on `??` and returned the raw text, so the quotes ended up + // in the email: `Hi {{name ?? 'there'}}` rendered as `Hi 'there'`. The documented + // behaviour has always been `Hi there`, which is what Liquid's `default:` gives. + expect(renderTemplate("Hi {{name ?? 'there'}}", {})).toBe('Hi there'); + expect(renderTemplate('Hi {{name ?? "there"}}', {})).toBe('Hi there'); + }); + + it('renders falsy values instead of blanking them', () => { + // The old lookup chained with `||`, so any falsy value fell through to the + // fallback and rendered empty. `{{subscribed}}` is documented as rendering + // true/false, which only works now. + expect(renderTemplate('[{{count}}]', {count: 0})).toBe('[0]'); + expect(renderTemplate('[{{subscribed}}]', {subscribed: false})).toBe('[false]'); + expect(renderTemplate('[{{a.b}}]', {a: {b: 0}})).toBe('[0]'); + }); + + it('no longer truncates a ?? fallback at a ?? inside quotes', () => { + expect(renderTemplate('{{name ?? "a ?? b"}}', {})).toBe('a ?? b'); + }); + + it('resolves placeholders that span lines and array indexes', () => { + // Both rendered as literal text / empty before. + expect(renderTemplate('{{\n name \n}}', {name: 'Ada'})).toBe('Ada'); + expect(renderTemplate('[{{items.0}}]', {items: ['a', 'b']})).toBe('[a]'); + }); + + it('evaluates balanced tag markup that used to be literal body copy', () => { + // The main upgrade risk: prose that happens to contain balanced Liquid markup is + // now executed rather than printed. Unbalanced markup still fails to parse and + // falls back, so it survives unchanged. + expect(renderTemplate('Docs: {% if x %}shown{% endif %} end', {})).toBe('Docs: end'); + expect(renderTemplate('Use {% if x %} in docs', {})).toBe('Use {% if x %} in docs'); + }); + }); + + // ======================================== + // LIQUID FEATURES REQUESTED IN THE TICKET + // ======================================== + describe('conditionals', () => { + it('branches on a contact field, the multilanguage case', () => { + const template = `{% if locale == 'es' %}Hola{% elsif locale == 'fr' %}Bonjour{% else %}Hello{% endif %}`; + + expect(renderTemplate(template, {locale: 'es'})).toBe('Hola'); + expect(renderTemplate(template, {locale: 'fr'})).toBe('Bonjour'); + expect(renderTemplate(template, {locale: 'de'})).toBe('Hello'); + expect(renderTemplate(template, {})).toBe('Hello'); + }); + + it('supports case/when', () => { + const template = `{% case plan %}{% when 'pro' %}20% off{% when 'free' %}Upgrade{% else %}Thanks{% endcase %}`; + + expect(renderTemplate(template, {plan: 'pro'})).toBe('20% off'); + expect(renderTemplate(template, {plan: 'free'})).toBe('Upgrade'); + expect(renderTemplate(template, {plan: 'enterprise'})).toBe('Thanks'); + }); + + it('supports comparison and boolean operators', () => { + expect(renderTemplate('{% if ltv > 100 and plan == "pro" %}VIP{% endif %}', {ltv: 240, plan: 'pro'})).toBe('VIP'); + expect(renderTemplate('{% if ltv > 100 and plan == "pro" %}VIP{% endif %}', {ltv: 40, plan: 'pro'})).toBe(''); + expect(renderTemplate('{% unless subscribed %}Resubscribe{% endunless %}', {subscribed: false})).toBe( + 'Resubscribe', + ); + }); + + it('treats a blank custom field as falsy', () => { + // Contact data comes from CSV imports where "column present but empty" is the + // norm, so `jsTruthy` is enabled rather than Liquid's Shopify-compatible default. + expect(renderTemplate('{% if firstName %}Hi {{firstName}}{% else %}Hi there{% endif %}', {firstName: ''})).toBe( + 'Hi there', + ); + }); + }); + + describe('loops', () => { + it('iterates an array of primitives', () => { + const template = '{% for item in items %}
  • {{item}}
  • {% endfor %}'; + + expect(renderTemplate(template, {items: ['a', 'b']})).toBe('
  • a
  • b
  • '); + }); + + it('iterates an array of objects with forloop metadata', () => { + const template = '{% for p in products %}{{forloop.index}}. {{p.name}} — {{p.price}}\n{% endfor %}'; + + expect(renderTemplate(template, {products: [{name: 'Pro', price: 10}, {name: 'Team', price: 20}]})).toBe( + '1. Pro — 10\n2. Team — 20\n', + ); + }); + + it('renders nothing for an empty or missing collection', () => { + expect(renderTemplate('{% for i in items %}x{% endfor %}', {items: []})).toBe(''); + expect(renderTemplate('{% for i in items %}x{% endfor %}', {})).toBe(''); + }); + }); + + describe('filters', () => { + it('applies string and number filters', () => { + expect(renderTemplate('{{name | upcase}}', {name: 'ada'})).toBe('ADA'); + expect(renderTemplate('{{tags | join: ", "}}', {tags: ['a', 'b']})).toBe('a, b'); + expect(renderTemplate('{{price | times: 0.8 | round: 2}}', {price: 50})).toBe('40'); + }); + + it('applies the default filter, the modern form of ??', () => { + expect(renderTemplate('{{firstName | default: "there"}}', {})).toBe('there'); + }); + + it('formats dates', () => { + expect(renderTemplate('{{signupDate | date: "%Y-%m"}}', {signupDate: '2026-05-06T12:00:00Z'})).toBe('2026-05'); + }); + + it('skips an unknown filter rather than failing the send', () => { + expect(renderTemplate('{{name | upcse}}', {name: 'ada'})).toBe('ada'); + }); + }); + + describe('assignment', () => { + it('supports assign for derived values, the pricing case', () => { + const template = + '{% assign discount = ltv | divided_by: 10 %}{% if discount > 20 %}20{% else %}{{discount}}{% endif %}% off'; + + expect(renderTemplate(template, {ltv: 150})).toBe('15% off'); + expect(renderTemplate(template, {ltv: 900})).toBe('20% off'); + }); + + it('supports capture', () => { + expect(renderTemplate('{% capture greeting %}Hi {{name}}{% endcapture %}{{greeting}}!', {name: 'Ada'})).toBe( + 'Hi Ada!', + ); + }); + }); + + describe('whitespace control and comments', () => { + it('trims with the dash markers', () => { + expect(renderTemplate('a{%- if true -%} b {%- endif -%}c', {})).toBe('abc'); + expect(renderTemplate('[{{- name -}}]', {name: 'x'})).toBe('[x]'); + }); + + it('drops comment blocks', () => { + expect(renderTemplate('a{% comment %}note{% endcomment %}b', {})).toBe('ab'); + }); + + it('emits raw blocks verbatim', () => { + expect(renderTemplate('{% raw %}{{ not a variable }}{% endraw %}', {'not a variable': 'x'})).toBe( + '{{ not a variable }}', + ); + }); + }); + + // ======================================== + // MARKUP TYPED IN THE RICH-TEXT EDITOR + // ======================================== + describe('HTML-escaped markup', () => { + it('decodes comparison operators escaped by the editor', () => { + expect(renderTemplate('{% if age > 18 %}adult{% endif %}', {age: 21})).toBe('adult'); + expect(renderTemplate('{% if age <= 18 %}minor{% endif %}', {age: 12})).toBe('minor'); + }); + + it('decodes escaped quotes and non-breaking spaces inside delimiters', () => { + expect(renderTemplate('{% if plan == "pro" %}Pro{% endif %}', {plan: 'pro'})).toBe('Pro'); + expect(renderTemplate('{% if plan == 'pro' %}Pro{% endif %}', {plan: 'pro'})).toBe('Pro'); + }); + + it('does not decode entities outside delimiters', () => { + expect(renderTemplate('Tom & Jerry {{name}}', {name: 'x'})).toBe('Tom & Jerry x'); + }); + }); + + // ======================================== + // SANDBOXING + // ======================================== + describe('sandboxing', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + it.each(['include', 'render', 'layout'])('refuses the {%% %s %%} tag, which reads from disk', tag => { + const template = `{% ${tag} 'package.json' %}`; + const result = validateTemplate(template); + + expect(result.valid).toBe(false); + expect(result.error).toContain(`{% ${tag} %} tag is not available`); + // The file's contents must never reach the rendered email. + expect(renderTemplate(template, {})).not.toContain('"dependencies"'); + }); + + it('does not expose prototype members of contact data', () => { + expect(renderTemplate('[{{profile.constructor}}][{{profile.__proto__}}]', {profile: {}})).toBe('[][]'); + }); + + it('aborts a runaway loop instead of blocking the worker', () => { + const result = renderTemplate('{% for i in (1..100000000) %}x{% endfor %}', {}); + + // Falls back to legacy substitution, which leaves the tag as literal text. + expect(result).toContain('{% for i in (1..100000000) %}'); + expect(console.warn).toHaveBeenCalled(); + }); + }); + + // ======================================== + // ERROR HANDLING + // ======================================== + describe('malformed templates', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + }); + + it('never throws', () => { + expect(() => renderTemplate('{% if %}{% endfor %}', {})).not.toThrow(); + expect(() => renderTemplate('{{ | | }}', {})).not.toThrow(); + }); + + it('falls back to placeholder substitution when parsing fails', () => { + // An unterminated `{{` used to be left as literal text; it still is, and the + // well-formed placeholder next to it still resolves. + expect(renderTemplate('{{name}} and {{ unclosed', {name: 'Ada'})).toBe('Ada and {{ unclosed'); + }); + + it('logs once per template rather than once per recipient', () => { + const template = '{% if %}'; + + for (let i = 0; i < 100; i += 1) { + renderTemplate(template, {}); + } + + expect(console.warn).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('compileTemplate', () => { + beforeEach(() => { + clearTemplateCache(); + }); + + it('renders one parsed template against many recipients', () => { + const compiled = compileTemplate('Hi {{name}}, you are on {{plan}}.'); + + expect(compiled.valid).toBe(true); + expect(compiled.render({name: 'Ada', plan: 'pro'})).toBe('Hi Ada, you are on pro.'); + expect(compiled.render({name: 'Bob', plan: 'free'})).toBe('Hi Bob, you are on free.'); + }); + + it('does not leak state between renders', () => { + const compiled = compileTemplate('{% assign total = price | plus: 10 %}{{total}}'); + + expect(compiled.render({price: 1})).toBe('11'); + expect(compiled.render({price: 2})).toBe('12'); + }); + + it('exposes the original source and reports an invalid template', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const compiled = compileTemplate('{% if %}'); + + expect(compiled.source).toBe('{% if %}'); + expect(compiled.valid).toBe(false); + expect(compiled.render({})).toBe('{% if %}'); + }); +}); + +describe('validateTemplate', () => { + beforeEach(() => { + clearTemplateCache(); + }); + + it('accepts legacy and Liquid syntax', () => { + expect(validateTemplate('Hi {{firstName ?? there}}!')).toEqual({valid: true}); + expect(validateTemplate('{% if locale == "es" %}Hola{% endif %}')).toEqual({valid: true}); + expect(validateTemplate('{{first name}}')).toEqual({valid: true}); + expect(validateTemplate('')).toEqual({valid: true}); + }); + + it('reports an unclosed tag with its position', () => { + const result = validateTemplate('line one\n{% if plan == "pro" %}Pro'); + + expect(result.valid).toBe(false); + expect(result.error).toBeTruthy(); + expect(result.error).not.toMatch(/line:\d+/); + expect(result.line).toBe(2); + expect(result.column).toBeGreaterThan(0); + }); + + it('reports an unclosed placeholder', () => { + expect(validateTemplate('Hi {{ name').valid).toBe(false); + }); + + it('reports an unknown filter, which rendering would silently skip', () => { + const result = validateTemplate('{{name | upcse}}'); + + expect(result.valid).toBe(false); + expect(result.error).toContain('upcse'); + }); + + it('reports an unknown tag', () => { + expect(validateTemplate('{% loop %}x{% endloop %}').valid).toBe(false); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 530387abb..acee767df 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,5 @@ export * from './schemas/index.js'; export * from './operators.js'; -export * from './template.js'; +export * from './template/index.js'; export * from './i18n/index.js'; export * from './unsubscribe.js'; diff --git a/packages/shared/src/template/engine.ts b/packages/shared/src/template/engine.ts new file mode 100644 index 000000000..d07ac28b7 --- /dev/null +++ b/packages/shared/src/template/engine.ts @@ -0,0 +1,92 @@ +import {Liquid} from 'liquidjs'; + +/** + * Tags that resolve a partial by name through the file system. Template bodies are + * authored by users, so leaving these enabled turns every template into an arbitrary + * file read on the worker's disk (`{% render '../../.env' %}`). We both remove the + * file system (`templates: {}` shadows the `root`/`partials`/`layouts` lookups) and + * replace the tags, so authors get a clear message instead of a confusing ENOENT. + */ +const BLOCKED_TAGS = ['include', 'render', 'layout'] as const; + +/** + * DoS ceilings. Templates are user input and are rendered once per recipient, so a + * single pathological template must not be able to stall a worker. The limits are far + * above anything a real email needs — a 60 KB template renders in tens of + * microseconds, so a 1 s render budget is roughly four orders of magnitude of slack. + */ +export const TEMPLATE_PARSE_LIMIT = 2_000_000; +export const TEMPLATE_RENDER_LIMIT_MS = 1_000; +export const TEMPLATE_MEMORY_LIMIT = 10_000_000; + +/** + * Key under which the render scope references itself. Liquid has no syntax for + * looking up a top-level key that contains a space, and CSV imports routinely produce + * them (a "First Name" header becomes the key `first name`). Templates referencing + * those keys are rewritten to `__plunk["first name"]` during preprocessing. + */ +export const SCOPE_ALIAS = '__plunk'; + +/** + * Preserves the pre-Liquid behaviour of outputting a bare array as an HTML list. + * + * Liquid joins arrays without a separator, which would silently mangle templates that + * relied on `{{ items }}` rendering `
  • ` elements. Registered as Liquid's + * `outputEscape` hook, which appends itself to every output's filter chain — so + * `{{ items | raw }}` opts out and `{% for %}` is unaffected. + */ +function renderArrayAsListItems(value: unknown): string { + if (!Array.isArray(value)) { + // Not necessarily a string: Liquid stringifies whatever we hand back, and + // deferring to it keeps Drops, dates and numbers formatted exactly as usual. + return value as string; + } + + return value.map(item => `
  • ${item ?? ''}
  • `).join('\n'); +} + +function createEngine({strictFilters}: {strictFilters: boolean}): Liquid { + const engine = new Liquid({ + // No file system access whatsoever — see BLOCKED_TAGS. + templates: {}, + relativeReference: false, + // JavaScript truthiness. Contact data arrives from CSV imports and API payloads + // where "key present but blank" is the norm, and Liquid's Shopify-compatible + // truthiness would make `{% if firstName %}` true for an empty string. + jsTruthy: true, + // Contact data is unsanitised user input; never walk its prototype chain. + ownPropertyOnly: true, + // An unknown variable renders as an empty string, matching the previous + // regex-based renderer. Templates are written against per-contact custom fields + // that legitimately differ from contact to contact, so this can't be strict. + strictVariables: false, + strictFilters, + parseLimit: TEMPLATE_PARSE_LIMIT, + renderLimit: TEMPLATE_RENDER_LIMIT_MS, + memoryLimit: TEMPLATE_MEMORY_LIMIT, + outputEscape: renderArrayAsListItems, + }); + + for (const name of BLOCKED_TAGS) { + engine.registerTag(name, { + parse() { + throw new Error(`The {% ${name} %} tag is not available in Plunk templates`); + }, + render() { + return ''; + }, + }); + } + + return engine; +} + +/** Engine used for every send. Lenient: unknown filters and variables are skipped. */ +export const renderEngine = createEngine({strictFilters: false}); + +/** + * Engine used by `validateTemplate` at authoring time. Identical except that unknown + * filters raise, so a typo like `{{ name | upcse }}` is reported while the author is + * still editing rather than silently dropping the value from every email. + */ +export const validationEngine = createEngine({strictFilters: true}); diff --git a/packages/shared/src/template/index.ts b/packages/shared/src/template/index.ts new file mode 100644 index 000000000..19a09c628 --- /dev/null +++ b/packages/shared/src/template/index.ts @@ -0,0 +1,198 @@ +import {LiquidError, type Template} from 'liquidjs'; + +import {renderEngine, SCOPE_ALIAS, validationEngine} from './engine.js'; +import {renderLegacyTemplate} from './legacy.js'; +import {preprocessTemplate} from './preprocess.js'; + +/** A template parsed once and ready to be rendered against many recipients. */ +export interface CompiledTemplate { + /** The original, unpreprocessed template string. */ + readonly source: string; + /** + * Whether the source parsed as Liquid. A template that failed to parse still renders + * — through the legacy placeholder renderer — so sends never break on a bad template. + */ + readonly valid: boolean; + render(variables: Record): string; +} + +export interface TemplateValidationResult { + valid: boolean; + /** Human-readable description of the first syntax error, if any. */ + error?: string; + line?: number; + column?: number; +} + +/** + * Parsed templates are cached so the existing per-email call sites (which pass the same + * campaign body once per recipient) pay the parse cost once per worker rather than once + * per send. Bounded on both entry count and template size to keep worker memory flat. + */ +const MAX_CACHE_ENTRIES = 32; +const MAX_CACHEABLE_LENGTH = 200_000; + +/** Cap on distinct templates we remember having warned about. */ +const MAX_REPORTED_SOURCES = 64; + +type ParseResult = {templates: Template[]; error?: undefined} | {templates?: undefined; error: Error}; + +const parseCache = new Map(); + +/** Bounded set of template sources already reported, so a bad template logs once. */ +const reportedSources = new Set(); + +function reportOnce(source: string, stage: string, error: unknown): void { + if (reportedSources.has(source)) { + return; + } + + if (reportedSources.size >= MAX_REPORTED_SOURCES) { + reportedSources.clear(); + } + reportedSources.add(source); + + const message = error instanceof Error ? error.message : String(error); + // console rather than signale: @plunk/shared also runs in the browser (editor preview). + console.warn(`[TEMPLATE] Failed to ${stage} template, falling back to plain variable substitution: ${message}`); +} + +function parse(source: string): ParseResult { + try { + return {templates: renderEngine.parse(preprocessTemplate(source))}; + } catch (error) { + reportOnce(source, 'parse', error); + return {error: error instanceof Error ? error : new Error(String(error))}; + } +} + +function parseCached(source: string): ParseResult { + const cached = parseCache.get(source); + if (cached) { + // Re-insert to mark as most recently used. + parseCache.delete(source); + parseCache.set(source, cached); + return cached; + } + + const result = parse(source); + + if (source.length <= MAX_CACHEABLE_LENGTH) { + if (parseCache.size >= MAX_CACHE_ENTRIES) { + const oldest = parseCache.keys().next().value; + if (oldest !== undefined) { + parseCache.delete(oldest); + } + } + parseCache.set(source, result); + } + + return result; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Flatten the render scope. + * + * Callers pass contact fields both spread at the top level and nested under `data`. + * Merging keeps both `{{firstName}}` and `{{data.firstName}}` working, with top-level + * keys winning — the same precedence the old renderer used. `SCOPE_ALIAS` lets + * preprocessed lookups reach keys whose names contain spaces. + */ +function buildScope(variables: Record): Record { + const nested = variables.data; + const scope: Record = isPlainObject(nested) ? {...nested, ...variables} : {...variables}; + scope[SCOPE_ALIAS] = scope; + return scope; +} + +/** + * Parse a template once so it can be rendered for many recipients. + * + * Never throws: a template that fails to parse falls back to plain `{{variable}}` + * substitution, and `valid` reports which path it took. Use `validateTemplate` to + * surface syntax errors to the author. + */ +export function compileTemplate(source: string): CompiledTemplate { + const parsed = parseCached(source); + + if (parsed.error) { + return { + source, + valid: false, + render: variables => renderLegacyTemplate(source, variables), + }; + } + + return { + source, + valid: true, + render: variables => { + try { + return String(renderEngine.renderSync(parsed.templates, buildScope(variables))); + } catch (error) { + // A render error means a runtime limit was hit (e.g. an unbounded loop). + reportOnce(source, 'render', error); + return renderLegacyTemplate(source, variables); + } + }, + }; +} + +/** + * Render an email template. + * + * Templates are Liquid, so conditionals, loops and filters are available on top of the + * `{{variable}}` and `{{variable ?? fallback}}` syntax Plunk has always supported: + * + * renderTemplate('Hello {{name}}!', {name: 'World'}) -> 'Hello World!' + * renderTemplate('Hello {{data.name}}!', {data: {name: 'World'}}) -> 'Hello World!' + * renderTemplate('Hello {{name ?? Guest}}!', {}) -> 'Hello Guest!' + * renderTemplate('{% if plan == "pro" %}Pro{% endif %}', {plan: 'pro'}) -> 'Pro' + * + * Never throws. For a template rendered against many recipients, prefer + * `compileTemplate` to hoist the parse out of the loop. + */ +export function renderTemplate(template: string, variables: Record): string { + return compileTemplate(template).render(variables); +} + +/** + * Check a template for syntax errors, for use at authoring time. + * + * Stricter than rendering: unknown filters are reported here but skipped at send time, + * so saving a template can flag a typo without ever blocking a send. + * + * Positions come from the preprocessed source. Preprocessing never adds or removes + * newlines, so `line` always matches the author's template; `column` can be off by the + * length of a rewrite earlier on the same line. + */ +export function validateTemplate(source: string): TemplateValidationResult { + try { + validationEngine.parse(preprocessTemplate(source)); + return {valid: true}; + } catch (error) { + if (LiquidError.is(error)) { + const [line, column] = error.token.getPosition(); + return { + valid: false, + // Liquid appends ", line:N, col:M" to the message; the position is returned + // separately so callers can format it themselves. + error: error.message.replace(/, line:\d+, col:\d+$/, ''), + line, + column, + }; + } + + return {valid: false, error: error instanceof Error ? error.message : String(error)}; + } +} + +/** Test seam: drop parsed templates and warning state. */ +export function clearTemplateCache(): void { + parseCache.clear(); + reportedSources.clear(); +} diff --git a/packages/shared/src/template.ts b/packages/shared/src/template/legacy.ts similarity index 51% rename from packages/shared/src/template.ts rename to packages/shared/src/template/legacy.ts index b1198b24e..528e9cc75 100644 --- a/packages/shared/src/template.ts +++ b/packages/shared/src/template/legacy.ts @@ -1,22 +1,25 @@ /** - * Render email template by replacing variables - * Supports {{variable}} and {{variable ?? defaultValue}} syntax - * Also supports nested access like {{data.firstName}} + * The pre-Liquid renderer, kept as a safety net. * - * Example: - * renderTemplate('Hello {{name}}!', { name: 'World' }) -> 'Hello World!' - * renderTemplate('Hello {{data.name}}!', { data: { name: 'World' } }) -> 'Hello World!' - * renderTemplate('Hello {{name ?? Guest}}!', {}) -> 'Hello Guest!' + * Templates are authored by users and stored indefinitely, so a template that Liquid + * refuses to parse must not take a campaign down with it. When parsing fails, + * `renderTemplate` falls back to this function: plain `{{variable}}` and + * `{{variable ?? default}}` placeholders still resolve, and anything Liquid-specific is + * left as literal text. `validateTemplate` is what surfaces the actual error to the + * author. + * + * Supports `{{variable}}`, `{{variable ?? defaultValue}}` and nested access + * (`{{data.firstName}}`). */ -export function renderTemplate(template: string, variables: Record): string { - return template.replace(/\{\{(.*?)\}\}/g, (match, key) => { +export function renderLegacyTemplate(template: string, variables: Record): string { + return template.replace(/\{\{(.*?)\}\}/g, (_match, key) => { const [mainKey, defaultValue] = key.split('??').map((s: string) => s.trim()); // Handle nested property access (e.g., data.firstName) const getValue = (obj: Record, path: string): unknown => { - return path.split('.').reduce((current: Record | unknown, key) => { + return path.split('.').reduce((current: Record | unknown, segment) => { if (current && typeof current === 'object' && !Array.isArray(current)) { - return (current as Record)[key]; + return (current as Record)[segment]; } return undefined; }, obj); @@ -35,4 +38,4 @@ export function renderTemplate(template: string, variables: Record 18 %}` + * reaches the API as `{% if age > 18 %}` and fails to tokenize. Inside the + * delimiters the content is code rather than prose, so decoding is always the intent. + */ +const ENTITIES: Record = { + '<': '<', + '>': '>', + '"': '"', + '"': '"', + ''': "'", + ''': "'", + '&': '&', + ' ': ' ', + ' ': ' ', +}; + +// A single pass, so `&gt;` correctly decodes to `>` rather than to `>`. +const ENTITY_PATTERN = /&(?:lt|gt|quot|apos|amp|nbsp|#34|#39|#160);/g; + +/** + * A plain variable path and nothing else — no filters, operators, quotes or brackets. + * Used to detect the legacy `{{first name}}` form, which Liquid cannot express. + */ +const BARE_PATH = /^[A-Za-z_][\w .-]*$/; + +/** `{% raw %}` content must survive verbatim, so preprocessing skips over it. */ +const RAW_TAG = /^\s*raw\s*$/; +const END_RAW_TAG = /\{%-?\s*endraw\s*-?%\}/; + +function decodeEntities(source: string): string { + return source.includes('&') ? source.replace(ENTITY_PATTERN, entity => ENTITIES[entity] ?? entity) : source; +} + +/** + * Peel off Liquid's whitespace-control markers so the expression in between can be + * rewritten without losing them: `{{- name -}}` -> `-`, `name`, `-`. + */ +function splitTrimMarkers(inner: string): {prefix: string; body: string; suffix: string} { + let prefix = ''; + let suffix = ''; + let body = inner; + + if (body.startsWith('-') || body.startsWith('+')) { + prefix = body.slice(0, 1); + body = body.slice(1); + } + + if (body.endsWith('-') || body.endsWith('+')) { + suffix = body.slice(-1); + body = body.slice(0, -1); + } + + return {prefix, body, suffix}; +} + +/** Split an expression on top-level `??`, ignoring occurrences inside string literals. */ +function splitDefaults(expression: string): string[] { + if (!expression.includes('??')) { + return [expression]; + } + + const parts: string[] = []; + let start = 0; + let quote: string | undefined; + + for (let index = 0; index < expression.length; index += 1) { + const char = expression[index]; + + if (quote) { + if (char === quote) { + quote = undefined; + } + continue; + } + + if (char === '"' || char === "'") { + quote = char; + continue; + } + + if (char === '?' && expression[index + 1] === '?') { + parts.push(expression.slice(start, index)); + index += 1; + start = index + 1; + } + } + + parts.push(expression.slice(start)); + return parts; +} + +/** + * Quote a legacy `?? fallback` operand. The old renderer always treated the fallback + * as a literal string, so it stays quoted rather than being resolved as a variable — + * `{{plan ?? free}}` keeps rendering "free" and not the (missing) `free` variable. + * Authors who want a variable fallback can use Liquid's `| default:` directly. + */ +function toLiquidLiteral(raw: string): string { + const value = raw.trim(); + + const alreadyQuoted = + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))); + if (alreadyQuoted) { + return value; + } + + if (!value.includes('"')) { + return `"${value}"`; + } + if (!value.includes("'")) { + return `'${value}'`; + } + + // LiquidJS string literals have no escape sequences, so a fallback containing both + // quote styles keeps its double quotes as an entity. + return `"${value.replace(/"/g, '"')}"`; +} + +/** + * Route a key containing spaces through the scope alias. `{{first name}}` becomes + * `{{ __plunk["first name"] }}` and `{{profile.first name}}` becomes + * `{{ __plunk["profile"]["first name"] }}`, mirroring how the old renderer resolved + * dotted paths segment by segment. + */ +function rewriteSpacedPath(expression: string): string { + const path = expression.trim(); + + if (!path.includes(' ') || !BARE_PATH.test(path)) { + return expression; + } + + const segments = path + .split('.') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0); + + if (segments.length === 0) { + return expression; + } + + return SCOPE_ALIAS + segments.map(segment => `[${JSON.stringify(segment)}]`).join(''); +} + +function rewriteOutput(inner: string): string { + const decoded = decodeEntities(inner); + const {prefix, body, suffix} = splitTrimMarkers(decoded); + + // The old renderer replaced `{{}}` with an empty string; Liquid rejects it outright. + if (body.trim().length === 0) { + return ''; + } + + const [head = '', ...fallbacks] = splitDefaults(body); + const expression = rewriteSpacedPath(head).trim(); + + if (decoded === inner && fallbacks.length === 0 && expression === head.trim()) { + return `${OUTPUT_OPEN}${inner}${OUTPUT_CLOSE}`; + } + + const defaults = fallbacks.map(fallback => ` | default: ${toLiquidLiteral(fallback)}`).join(''); + return `${OUTPUT_OPEN}${prefix} ${expression}${defaults} ${suffix}${OUTPUT_CLOSE}`; +} + +/** + * Bridge Plunk's historic template syntax to Liquid, and undo the escaping the + * rich-text editor applies to markup typed inside `{{ }}` / `{% %}`. + * + * Runs once per template at parse time, never per recipient. + */ +export function preprocessTemplate(source: string): string { + if (!source.includes(OUTPUT_OPEN) && !source.includes(TAG_OPEN)) { + return source; + } + + let result = ''; + let index = 0; + + while (index < source.length) { + const outputAt = source.indexOf(OUTPUT_OPEN, index); + const tagAt = source.indexOf(TAG_OPEN, index); + + if (outputAt === -1 && tagAt === -1) { + break; + } + + const isOutput = tagAt === -1 || (outputAt !== -1 && outputAt < tagAt); + const openAt = isOutput ? outputAt : tagAt; + const closeAt = source.indexOf(isOutput ? OUTPUT_CLOSE : TAG_CLOSE, openAt + 2); + + if (closeAt === -1) { + // Unterminated delimiter: leave the remainder untouched and let Liquid report it. + break; + } + + result += source.slice(index, openAt); + const inner = source.slice(openAt + 2, closeAt); + + if (isOutput) { + result += rewriteOutput(inner); + index = closeAt + 2; + continue; + } + + const {body} = splitTrimMarkers(inner); + + if (RAW_TAG.test(body)) { + // Copy `{% raw %}...{% endraw %}` through byte for byte. + const rest = source.slice(closeAt + 2); + const endRaw = END_RAW_TAG.exec(rest); + const rawEnd = endRaw ? closeAt + 2 + endRaw.index + endRaw[0].length : source.length; + result += source.slice(openAt, rawEnd); + index = rawEnd; + continue; + } + + result += `${TAG_OPEN}${decodeEntities(inner)}${TAG_CLOSE}`; + index = closeAt + 2; + } + + return result + source.slice(index); +} diff --git a/test/performance/template-rendering.perf.test.ts b/test/performance/template-rendering.perf.test.ts new file mode 100644 index 000000000..e30a726ac --- /dev/null +++ b/test/performance/template-rendering.perf.test.ts @@ -0,0 +1,248 @@ +import {beforeEach, describe, expect, it} from 'vitest'; + +import {clearTemplateCache, compileTemplate, renderTemplate} from '../../packages/shared/src/template/index'; + +/** + * Performance Tests: Liquid template rendering + * + * Templates are rendered once per recipient, so template rendering sits directly on + * the campaign send path — a campaign to 1M contacts renders 2M templates (subject + + * body). Issue #426 flagged CPU cost as the main risk of adopting a real templating + * language, so these tests pin the throughput floor. + * + * Measured on a developer machine (logged by every run, so regressions show as a trend + * rather than only as a failure): + * + * | Scenario | Result | + * | ---------------------------------------------------- | --------------- | + * | Small template, parse hoisted (issue #426 benchmark) | ~142k renders/s | + * | Realistic campaign body (loop + filters + branches) | ~18k renders/s | + * | Same body via renderTemplate (parse from cache) | ~0.05 ms/render | + * | Parsing a 100 KB template | ~15 ms (once) | + * | Full 500-recipient batch, subject + body | ~30 ms | + * + * At ~0.05 ms/render a 1M-contact campaign spends under a minute of CPU on rendering, + * which is negligible next to 1M SES calls. The assertions below are set well below the + * measured values so they hold on a loaded CI box. + * + * Performance Targets: + * - Rendering must never dominate the per-email cost (network + SES call): < 1ms/email + * - Parsing must be hoisted or cached, never repeated per recipient + * - Memory must stay flat regardless of how many distinct templates are rendered + */ +describe('Performance: Template Rendering at Scale', () => { + /** A realistic marketing email: conditionals, a loop, filters and fallbacks. */ + const CAMPAIGN_TEMPLATE = ` + + +
    + {% if locale == 'es' %} +

    Hola {{firstName ?? cliente}}

    + {% elsif locale == 'fr' %} +

    Bonjour {{firstName ?? client}}

    + {% else %} +

    Hi {{firstName ?? there}}

    + {% endif %} + +

    You have been on the {{plan | upcase}} plan since {{signupDate | date: "%B %Y"}}.

    + + {% assign discount = ltv | divided_by: 20 %} + {% if discount > 25 %}{% assign discount = 25 %}{% endif %} +

    Here is {{discount}}% off your next renewal.

    + +
      + {% for product in products %} +
    • {{forloop.index}}. {{product.name}} — \${{product.price | times: 1.0 | round: 2}}
    • + {% endfor %} +
    + + Unsubscribe +
    + +`; + + const contactVariables = (index: number) => ({ + id: `contact-${index}`, + email: `contact${index}@example.com`, + firstName: index % 3 === 0 ? '' : `Contact ${index}`, + locale: ['en', 'es', 'fr'][index % 3], + plan: index % 2 === 0 ? 'pro' : 'free', + ltv: index % 1000, + signupDate: '2026-05-06T12:00:00Z', + products: [ + {name: 'Starter', price: 9.99}, + {name: 'Team', price: 29.99}, + ], + unsubscribeUrl: `https://example.com/unsubscribe/${index}`, + }); + + beforeEach(() => { + clearTemplateCache(); + }); + + // ======================================== + // THROUGHPUT + // ======================================== + describe('Render throughput', () => { + it('reproduces the issue #426 benchmark at over 50k renders/second', () => { + // Same shape as the benchmark posted on the issue: a small template with one + // variable, one filter and one conditional, parsed once outside the loop. Kept + // separate from the realistic template below so the published number stays + // directly comparable. + const ITERATIONS = 50_000; + const compiled = compileTemplate( + `

    Hello {{ recipient.name }},

    +

    This is render number {{ i }}.

    + {% assign remainder = i | modulo: 2 %} + {% if remainder == 0 %}

    {{ i }} is even.

    {% else %}

    {{ i }} is odd.

    {% endif %}`, + ); + + const startTime = performance.now(); + for (let i = 0; i < ITERATIONS; i += 1) { + compiled.render({i, recipient: {name: `My Name ${i}`}}); + } + const duration = performance.now() - startTime; + + const rendersPerSecond = (ITERATIONS / duration) * 1000; + console.log(`[PERF] issue #426 benchmark: ${Math.round(rendersPerSecond).toLocaleString()} renders/s`); + + expect(rendersPerSecond).toBeGreaterThan(50_000); + }, 60000); + + it('renders a realistic campaign body at over 5k renders/second', () => { + const ITERATIONS = 20_000; + const compiled = compileTemplate(CAMPAIGN_TEMPLATE); + expect(compiled.valid).toBe(true); + + const startTime = performance.now(); + let characters = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + characters += compiled.render(contactVariables(i)).length; + } + const duration = performance.now() - startTime; + + const rendersPerSecond = (ITERATIONS / duration) * 1000; + console.log( + `[PERF] compiled render: ${Math.round(rendersPerSecond).toLocaleString()} renders/s ` + + `(${(duration / ITERATIONS).toFixed(4)} ms/render, ${characters.toLocaleString()} chars)`, + ); + + expect(characters).toBeGreaterThan(0); + expect(rendersPerSecond).toBeGreaterThan(5_000); + }, 60000); + + it('keeps renderTemplate within 1ms/email by caching the parse', () => { + // This is the path every individual send takes (EmailService.format), where the + // caller has no compiled template to hand — the parse must come from the cache. + const ITERATIONS = 10_000; + + const startTime = performance.now(); + for (let i = 0; i < ITERATIONS; i += 1) { + renderTemplate(CAMPAIGN_TEMPLATE, contactVariables(i)); + } + const duration = performance.now() - startTime; + + const msPerRender = duration / ITERATIONS; + console.log(`[PERF] renderTemplate: ${msPerRender.toFixed(4)} ms/render`); + + expect(msPerRender).toBeLessThan(1); + }, 60000); + + it('parses a 100KB template in under 100ms', () => { + // Parsing happens once per template, so it only needs to be cheap enough not to + // stall the first email of a batch. + const large = CAMPAIGN_TEMPLATE.repeat(Math.ceil(100_000 / CAMPAIGN_TEMPLATE.length)); + + const startTime = performance.now(); + const compiled = compileTemplate(large); + const duration = performance.now() - startTime; + + console.log(`[PERF] parse ${(large.length / 1024).toFixed(0)}KB template: ${duration.toFixed(2)} ms`); + + expect(compiled.valid).toBe(true); + expect(duration).toBeLessThan(100); + }, 60000); + }); + + // ======================================== + // CAMPAIGN BATCH BUDGET + // ======================================== + describe('Campaign batch', () => { + it('renders a 500-recipient batch (subject + body) in under 500ms', () => { + // CampaignService.processBatch handles BATCH_SIZE = 500 contacts per job and + // renders both the subject and the body for each one. + const BATCH_SIZE = 500; + const subject = compileTemplate('{% if locale == "es" %}Tu oferta{% else %}Your offer{% endif %}, {{firstName ?? there}}'); + const body = compileTemplate(CAMPAIGN_TEMPLATE); + + const startTime = performance.now(); + for (let i = 0; i < BATCH_SIZE; i += 1) { + const variables = contactVariables(i); + subject.render(variables); + body.render(variables); + } + const duration = performance.now() - startTime; + + console.log(`[PERF] 500-recipient batch: ${duration.toFixed(2)} ms`); + + expect(duration).toBeLessThan(500); + }, 60000); + + it('produces per-recipient output rather than a shared render', () => { + const compiled = compileTemplate(CAMPAIGN_TEMPLATE); + const rendered = new Set(); + + for (let i = 0; i < 100; i += 1) { + rendered.add(compiled.render(contactVariables(i))); + } + + // 100 contacts vary by name, locale, plan and ltv — none should collide. + expect(rendered.size).toBe(100); + }); + }); + + // ======================================== + // MEMORY + // ======================================== + describe('Memory', () => { + it('keeps the parse cache bounded across many distinct templates', () => { + // A worker sees templates from every project it serves. The cache is capped, so + // cycling through far more templates than it holds must not accumulate ASTs. + const TEMPLATE_COUNT = 500; + const templates = Array.from( + {length: TEMPLATE_COUNT}, + (_, i) => `${CAMPAIGN_TEMPLATE}{{firstName ?? there}}`, + ); + + const initialMemory = process.memoryUsage().heapUsed; + + for (const template of templates) { + renderTemplate(template, contactVariables(1)); + } + + const memoryIncrease = (process.memoryUsage().heapUsed - initialMemory) / 1024 / 1024; + console.log(`[PERF] ${TEMPLATE_COUNT} distinct templates: +${memoryIncrease.toFixed(1)} MB heap`); + + expect(memoryIncrease).toBeLessThan(100); + }, 60000); + + it('does not grow the heap while rendering one template repeatedly', () => { + const compiled = compileTemplate(CAMPAIGN_TEMPLATE); + + // Warm up so lazily-allocated internals are not counted as growth. + for (let i = 0; i < 1_000; i += 1) { + compiled.render(contactVariables(i)); + } + + const initialMemory = process.memoryUsage().heapUsed; + for (let i = 0; i < 50_000; i += 1) { + compiled.render(contactVariables(i)); + } + const memoryIncrease = (process.memoryUsage().heapUsed - initialMemory) / 1024 / 1024; + + console.log(`[PERF] 50k renders: +${memoryIncrease.toFixed(1)} MB heap`); + + expect(memoryIncrease).toBeLessThan(100); + }, 60000); + }); +}); diff --git a/yarn.lock b/yarn.lock index 27fade442..e0c6fcbd5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3154,6 +3154,7 @@ __metadata: "@plunk/types": "npm:*" "@plunk/typescript-config": "npm:*" "@types/node": "npm:^24.10.0" + liquidjs: "npm:^10.27.2" typescript: "npm:^5.7.2" zod: "npm:^3.23.8" languageName: unknown @@ -8888,6 +8889,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^10.0.0": + version: 10.0.1 + resolution: "commander@npm:10.0.1" + checksum: 10c0/53f33d8927758a911094adadda4b2cbac111a5b377d8706700587650fd8f45b0bbe336de4b5c3fe47fd61f420a3d9bd452b6e0e6e5600a7e74d7bf0174f6efe3 + languageName: node + linkType: hard + "commander@npm:^12.1.0": version: 12.1.0 resolution: "commander@npm:12.1.0" @@ -13439,6 +13447,18 @@ __metadata: languageName: node linkType: hard +"liquidjs@npm:^10.27.2": + version: 10.27.2 + resolution: "liquidjs@npm:10.27.2" + dependencies: + commander: "npm:^10.0.0" + bin: + liquid: bin/liquid.js + liquidjs: bin/liquid.js + checksum: 10c0/aac43b9b0296914de6031cc6957682a6391949ca5c4a3b914b762352c0f2ac8849bc42d85b82ae25c8168945fc9b45b378ca3654b375cde37b44497c43a6f6e8 + languageName: node + linkType: hard + "load-json-file@npm:^4.0.0": version: 4.0.0 resolution: "load-json-file@npm:4.0.0"