From fb5aa8796a8550be7a70fdd91616c9a41ed3047d Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 2 Apr 2026 12:43:27 +0200 Subject: [PATCH] feat: Add headless template type --- apps/api/src/jobs/email-processor.ts | 5 +- apps/api/src/services/EmailService.ts | 4 +- .../services/__tests__/EmailService.test.ts | 55 ++++++++++ .../__tests__/TemplateService.test.ts | 18 ++++ .../components/TemplateSelectionDialog.tsx | 15 ++- apps/web/src/pages/campaigns/[id].tsx | 77 +++++++------ apps/web/src/pages/campaigns/create.tsx | 42 +++++++- apps/web/src/pages/templates/[id].tsx | 78 ++++++++------ apps/web/src/pages/templates/create.tsx | 101 +++++++++++------- apps/web/src/pages/templates/index.tsx | 12 ++- apps/wiki/content/docs/concepts/contacts.mdx | 7 +- apps/wiki/content/docs/concepts/templates.mdx | 11 +- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 1 + packages/shared/src/index.ts | 1 + packages/shared/src/unsubscribe.ts | 26 +++++ 16 files changed, 334 insertions(+), 121 deletions(-) create mode 100644 packages/db/prisma/migrations/20260402100744_add_headless_template_type/migration.sql create mode 100644 packages/shared/src/unsubscribe.ts diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 8436fd2..cceb5f0 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -58,6 +58,7 @@ export async function createEmailWorker() { include: { contact: true, project: true, + template: {select: {type: true}}, }, }); @@ -105,11 +106,13 @@ export async function createEmailWorker() { }); // Compile HTML with unsubscribe footer and badge + // TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer const compiledHtml = EmailService.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 fromName from database if available, otherwise fall back to project name diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 0f07c5f..abe638d 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -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 diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 4db3cc5..134d274 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -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 unsubscribe', + 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({ diff --git a/apps/api/src/services/__tests__/TemplateService.test.ts b/apps/api/src/services/__tests__/TemplateService.test.ts index f7adc10..b472a1c 100644 --- a/apps/api/src/services/__tests__/TemplateService.test.ts +++ b/apps/api/src/services/__tests__/TemplateService.test.ts @@ -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}); diff --git a/apps/web/src/components/TemplateSelectionDialog.tsx b/apps/web/src/components/TemplateSelectionDialog.tsx index c1597ad..410a08c 100644 --- a/apps/web/src/components/TemplateSelectionDialog.tsx +++ b/apps/web/src/components/TemplateSelectionDialog.tsx @@ -39,7 +39,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}: const [search, setSearch] = useState(''); const [searchInput, setSearchInput] = useState(''); const [page, setPage] = useState(1); - const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL'); + const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING' | 'HEADLESS'>('ALL'); const [step, setStep] = useState<'select' | 'configure'>('select'); const [selectedTemplate, setSelectedTemplate] = useState