From 39249d31f9673c212a035a377eefa62368afac43 Mon Sep 17 00:00:00 2001 From: Pau Sanchez Date: Sun, 26 Jul 2026 10:57:01 +0200 Subject: [PATCH 1/2] feat!: implement liquidjs templating system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates are parsed once and rendered per recipient, so a basic template can render at 140k renders/s whereas a richer one with sevearl conditionals and loops renders at ~18k renders/s. 1M-contact campaign spends under one minute of CPU time for rendering, which is negligible against 1M SES calls. The engine is sandboxed: {% include %}, {% render %} and {% layout %} are rejected because they resolve partials off the file system, and parse/render/memory are capped so a runaway loop can't stall a worker. Rendering stays lenient, a template that fails to parse falls back to plain {{variable}} substitution rather than failing the send. Syntax errors are surfaced at write time instead: POST/PATCH of templates and campaigns, and POST /v1/send with an inline subject/body, now return 400 with the line and column. Adding multiple tests to assess the proper behaviour and to keep some performance guarantees (for both memory & CPU). Backwards compatible with existing template system for most scenarios. See template-language.mdx Notes for existing templates for more info, but basically: 0/false/NaN are now rendered while before they produced empty string. Also {{ name ?? 'hi' }} produced 'hi' with quotes, which seems like a bug, now emits hi with no quotes. Body copy containing balanced {% %} markup is now executed rather than printed literally — wrap it in {% raw %} to keep it as text. --- apps/api/src/controllers/Actions.ts | 5 + apps/api/src/controllers/Campaigns.ts | 5 + apps/api/src/controllers/Templates.ts | 5 + apps/api/src/services/CampaignService.ts | 19 +- apps/api/src/services/EmailService.ts | 8 +- .../__tests__/CampaignService.test.ts | 74 ++++ .../__tests__/templateValidation.test.ts | 64 +++ apps/api/src/utils/templateValidation.ts | 32 ++ apps/wiki/content/docs/concepts/templates.mdx | 41 +- .../content/docs/guides/custom-fields.mdx | 12 +- apps/wiki/content/docs/guides/meta.json | 1 + .../content/docs/guides/template-language.mdx | 190 +++++++++ packages/shared/package.json | 1 + .../shared/src/__tests__/template.test.ts | 402 ++++++++++++++++++ packages/shared/src/index.ts | 2 +- packages/shared/src/template/engine.ts | 92 ++++ packages/shared/src/template/index.ts | 198 +++++++++ .../src/{template.ts => template/legacy.ts} | 27 +- packages/shared/src/template/preprocess.ts | 231 ++++++++++ .../template-rendering.perf.test.ts | 248 +++++++++++ yarn.lock | 20 + 21 files changed, 1648 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/utils/__tests__/templateValidation.test.ts create mode 100644 apps/api/src/utils/templateValidation.ts create mode 100644 apps/wiki/content/docs/guides/template-language.mdx create mode 100644 packages/shared/src/__tests__/template.test.ts create mode 100644 packages/shared/src/template/engine.ts create mode 100644 packages/shared/src/template/index.ts rename packages/shared/src/{template.ts => template/legacy.ts} (51%) create mode 100644 packages/shared/src/template/preprocess.ts create mode 100644 test/performance/template-rendering.perf.test.ts 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/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" From 19ac14cca261d590a4616b75a00fd69caf243f96 Mon Sep 17 00:00:00 2001 From: Pau Sanchez Date: Sun, 26 Jul 2026 13:00:27 +0200 Subject: [PATCH 2/2] fix: stub remaining SES calls in DomainService tests verifyDomain and getDomainVerificationAttributes were already mocked, but deleteIdentity and disableFeedbackForwarding were not, so the suite issued live AWS SES requests on every run. DomainService catches and logs failures from both, so the calls stayed invisible in test output while still going over the network. With real SES credentials in .env they would have executed DeleteIdentity and SetIdentityFeedbackForwardingEnabled against a live AWS account. DomainService.test.ts drops from ~40s to 23s. --- apps/api/src/services/__tests__/DomainService.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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); }); // ========================================