feat: Add headless template type

This commit is contained in:
Dries Augustyns
2026-04-02 12:43:27 +02:00
parent 284838279d
commit fb5aa8796a
16 changed files with 334 additions and 121 deletions
+3 -1
View File
@@ -357,11 +357,13 @@ export class EmailService {
});
// Compile HTML with unsubscribe footer and badge
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
const compiledHtml = this.compile({
content: formattedEmail.body,
contact: email.contact,
project: email.project,
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
includeUnsubscribe:
email.sourceType !== EmailSourceType.TRANSACTIONAL && email.template?.type !== 'HEADLESS',
});
// Use explicit fromName if provided, otherwise fall back to project name
@@ -187,6 +187,61 @@ describe('EmailService', () => {
});
});
describe('Headless Email Behaviour', () => {
it('should NOT send headless workflow emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const headlessTemplate = await factories.createTemplate({
projectId,
type: 'HEADLESS',
});
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: headlessTemplate.id,
subject: 'Newsletter',
body: 'Content',
from: 'test@example.com',
workflowExecutionId: execution.id,
});
expect(email.status).toBe(EmailStatus.FAILED);
expect(email.error).toMatch(/unsubscribed/i);
});
it('should keep CAMPAIGN sourceType when campaign uses headless template', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
const headlessTemplate = await factories.createTemplate({
projectId,
type: 'HEADLESS',
});
const campaign = await factories.createCampaign({projectId});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
templateId: headlessTemplate.id,
subject: 'Newsletter',
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
from: 'news@example.com',
});
// HEADLESS is not transactional — sourceType stays CAMPAIGN
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
expect(email.status).toBe(EmailStatus.PENDING);
});
});
describe('Template Type Determines Email Type', () => {
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
const contact = await factories.createContact({
@@ -193,6 +193,7 @@ describe('TemplateService', () => {
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
await factories.createTemplate({projectId, type: TemplateType.HEADLESS});
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2);
@@ -201,6 +202,10 @@ describe('TemplateService', () => {
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1);
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
const headlessResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.HEADLESS);
expect(headlessResult.total).toBe(1);
expect(headlessResult.data[0].type).toBe(TemplateType.HEADLESS);
});
it('should combine search and type filters', async () => {
@@ -293,6 +298,19 @@ describe('TemplateService', () => {
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
});
it('should update template type to HEADLESS', async () => {
const template = await factories.createTemplate({
projectId,
type: TemplateType.MARKETING,
});
const updated = await TemplateService.update(projectId, template.id, {
type: TemplateType.HEADLESS,
});
expect(updated.type).toBe(TemplateType.HEADLESS);
});
it('should update email fields (from, fromName, replyTo)', async () => {
const template = await factories.createTemplate({projectId});