Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/api/src/controllers/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 => {
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/controllers/Campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/controllers/Templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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!);

Expand Down Expand Up @@ -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!);
Expand Down
19 changes: 8 additions & 11 deletions apps/api/src/services/CampaignService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/services/EmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>}): {
subject: string;
Expand Down
74 changes: 74 additions & 0 deletions apps/api/src/services/__tests__/CampaignService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[]) {
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: '<p>Hi {{firstName ?? there}}, you are on {{plan | upcase}}.</p>',
},
[
{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<string, unknown>)?.firstName ?? null, email]),
);

expect(byFirstName.get('Ada')?.subject).toBe('Your offer');
expect(byFirstName.get('Ada')?.body).toBe('<p>Hi Ada, you are on PRO.</p>');

expect(byFirstName.get('Bruno')?.subject).toBe('Tu oferta');
expect(byFirstName.get('Bruno')?.body).toBe('<p>Hi Bruno, you are on FREE.</p>');

// Contact without a firstName falls back
expect(byFirstName.get(null)?.body).toBe('<p>Hi there, you are on FREE.</p>');
});

it('renders loops over contact data', async () => {
const emails = await sendBatch(
{
subject: 'Your cart',
body: '<ul>{% for item in cart %}<li>{{item.name}}</li>{% endfor %}</ul>',
},
[{cart: [{name: 'Starter'}, {name: 'Team'}]}],
);

expect(emails[0]?.body).toBe('<ul><li>Starter</li><li>Team</li></ul>');
});

it('still injects the per-recipient unsubscribe URL', async () => {
const emails = await sendBatch({subject: 'Hi', body: '<a href="{{unsubscribeUrl}}">Unsubscribe</a>'}, [{}]);

expect(emails[0]?.body).toContain(`/unsubscribe/${emails[0]?.contactId}`);
});
});
});
5 changes: 5 additions & 0 deletions apps/api/src/services/__tests__/DomainService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

// ========================================
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/utils/__tests__/templateValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>{{data.plan}}</p>'}),
).not.toThrow();
});

it('accepts Liquid conditionals, loops and filters', () => {
expect(() =>
assertValidTemplateSyntax({
subject: '{% if locale == "es" %}Hola{% else %}Hi{% endif %}',
body: '<ul>{% for item in cart %}<li>{{item.name | upcase}}</li>{% endfor %}</ul>',
}),
).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: '<p>ok</p>\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/);
});
});
32 changes: 32 additions & 0 deletions apps/api/src/utils/templateValidation.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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},
);
}
}
}
41 changes: 39 additions & 2 deletions apps/wiki/content/docs/concepts/templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<p>Hi {{firstName ?? 'there'}},</p>

{% if locale == 'es' %}
<p>Tu plan {{plan | upcase}} se renueva pronto.</p>
{% else %}
<p>Your {{plan | upcase}} plan renews soon.</p>
{% endif %}
```

#### Always Available Variables

Expand All @@ -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' %}<p>Here's 20% off your renewal.</p>
{% when 'free' %}<p>Upgrade and save 20%.</p>
{% else %}<p>Thanks for being with us.</p>
{% endcase %}

<ul>
{% for item in cartItems %}
<li>{{item.name}} — {{item.price | times: quantity | round: 2}}</li>
{% endfor %}
</ul>
```

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

Expand Down Expand Up @@ -93,6 +127,9 @@ There are three types of templates in Plunk. Each type is treated at the same pr
## What's next

<Cards>
<Card title="Template language" href="/guides/template-language">
Conditionals, loops and filters with Liquid.
</Card>
<Card title="Transactional emails" href="/concepts/transactional-emails">
Send templated emails directly through `/v1/send`.
</Card>
Expand Down
Loading