Skip to content
Merged
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
8 changes: 8 additions & 0 deletions apps/api/src/controllers/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ 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, but they are deliberately NOT syntax
// checked here. Transactional bodies are generated by whatever system calls us —
// Handlebars output, front-end framework markup, an unbalanced `{{` in a code
// sample — and those sent fine before Liquid existed. Rejecting them now would
// break live integrations over markup the renderer already handles by falling back
// to plain placeholder substitution. Authoring-time surfaces (templates, campaigns)
// are where a syntax error is worth failing the write.

// 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/);
});
});
36 changes: 36 additions & 0 deletions apps/api/src/utils/templateValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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.
*
* Only for authoring surfaces — templates and campaigns, where a human is editing and
* an audience is at stake. `/v1/send` is left unchecked on purpose: its bodies come
* from other systems and rendering already degrades gracefully.
*/
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},
);
}
}
}
Loading
Loading