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
+4 -1
View File
@@ -58,6 +58,7 @@ export async function createEmailWorker() {
include: { include: {
contact: true, contact: true,
project: true, project: true,
template: {select: {type: true}},
}, },
}); });
@@ -105,11 +106,13 @@ export async function createEmailWorker() {
}); });
// Compile HTML with unsubscribe footer and badge // Compile HTML with unsubscribe footer and badge
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
const compiledHtml = EmailService.compile({ const compiledHtml = EmailService.compile({
content: formattedEmail.body, content: formattedEmail.body,
contact: email.contact, contact: email.contact,
project: email.project, 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 // Use fromName from database if available, otherwise fall back to project name
+3 -1
View File
@@ -357,11 +357,13 @@ export class EmailService {
}); });
// Compile HTML with unsubscribe footer and badge // Compile HTML with unsubscribe footer and badge
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
const compiledHtml = this.compile({ const compiledHtml = this.compile({
content: formattedEmail.body, content: formattedEmail.body,
contact: email.contact, contact: email.contact,
project: email.project, 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 // 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: '[email protected]',
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: '[email protected]',
});
// 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', () => { describe('Template Type Determines Email Type', () => {
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => { it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
const contact = await factories.createContact({ 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.MARKETING}); await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL}); 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); const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2); expect(marketingResult.total).toBe(2);
@@ -201,6 +202,10 @@ describe('TemplateService', () => {
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL); const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1); expect(transactionalResult.total).toBe(1);
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL); 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 () => { it('should combine search and type filters', async () => {
@@ -293,6 +298,19 @@ describe('TemplateService', () => {
expect(updated.type).toBe(TemplateType.TRANSACTIONAL); 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 () => { it('should update email fields (from, fromName, replyTo)', async () => {
const template = await factories.createTemplate({projectId}); const template = await factories.createTemplate({projectId});
@@ -39,7 +39,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [page, setPage] = useState(1); 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 [step, setStep] = useState<'select' | 'configure'>('select');
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [selectedFields, setSelectedFields] = useState<SelectedFields>({ const [selectedFields, setSelectedFields] = useState<SelectedFields>({
@@ -186,6 +186,17 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
> >
Transactional Transactional
</Button> </Button>
<Button
type="button"
onClick={() => {
setTypeFilter('HEADLESS');
setPage(1);
}}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div> </div>
</div> </div>
@@ -233,7 +244,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base truncate">{template.name}</CardTitle> <CardTitle className="text-base truncate">{template.name}</CardTitle>
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}> <Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}>
{template.type.toLowerCase()} {template.type.toLowerCase()}
</Badge> </Badge>
</div> </div>
+46 -31
View File
@@ -30,7 +30,7 @@ import {
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db'; import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared'; import {CampaignSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings'; import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor'; import {EmailEditor} from '../../components/EmailEditor';
@@ -49,6 +49,7 @@ import {
TestTube, TestTube,
Trash2, Trash2,
TrendingUp, TrendingUp,
TriangleAlert,
Users, Users,
XCircle, XCircle,
} from 'lucide-react'; } from 'lucide-react';
@@ -467,36 +468,49 @@ export default function CampaignDetailsPage() {
<div> <div>
<Label>Campaign Type</Label> <Label>Campaign Type</Label>
<div className="grid grid-cols-2 gap-3 mt-2"> <div className="flex flex-col gap-2 mt-2">
<button {([
type="button" {value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.MARKETING})} {value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
className={`text-left p-3 rounded-lg border-2 transition-colors ${ {value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
(editedCampaign.type ?? c.type) === TemplateType.MARKETING ] as const).map(({value, label, description}) => (
? 'border-neutral-900 bg-neutral-50' <button
: 'border-neutral-200 hover:border-neutral-300' key={value}
}`} type="button"
> onClick={() => setEditedCampaign({...editedCampaign, type: value})}
<p className="font-medium text-sm text-neutral-900">Marketing</p> className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
<p className="text-xs text-neutral-500 mt-1"> (editedCampaign.type ?? c.type) === value
Subscribed contacts only, includes unsubscribe link. ? 'border-neutral-900 bg-neutral-50'
</p> : 'border-neutral-200 hover:border-neutral-300'
</button> }`}
<button >
type="button" <span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.TRANSACTIONAL})} <span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
className={`text-left p-3 rounded-lg border-2 transition-colors ${ </button>
(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ))}
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Transactional</p>
<p className="text-xs text-neutral-500 mt-1">
All contacts regardless of subscription. No unsubscribe footer.
</p>
</button>
</div> </div>
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
</div>
<div className="px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-800 leading-relaxed">
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
</p>
<div className="flex flex-wrap gap-1.5">
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{unsubscribeUrl}}'}
</code>
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{manageUrl}}'}
</code>
</div>
</div>
</div>
)}
</div> </div>
<div> <div>
@@ -622,7 +636,8 @@ export default function CampaignDetailsPage() {
differ if contacts{' '} differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL {(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.' ? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'} : 'subscribe, unsubscribe, or segment membership changes.'
}
</p> </p>
</div> </div>
</div> </div>
+39 -3
View File
@@ -23,12 +23,13 @@ import {EmailEditor} from '../../components/EmailEditor';
import {StepHeader} from '../../components/StepHeader'; import {StepHeader} from '../../components/StepHeader';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation'; import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save, Users} from 'lucide-react'; import {ArrowLeft, Save, TriangleAlert, Users} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import {detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateCampaignPage() { export default function CreateCampaignPage() {
@@ -268,7 +269,7 @@ export default function CreateCampaignPage() {
/> />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-3 gap-3">
<button <button
type="button" type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)} onClick={() => setCampaignType(TemplateType.MARKETING)}
@@ -297,7 +298,42 @@ export default function CreateCampaignPage() {
Sent to all contacts regardless of subscription status. No unsubscribe footer. Sent to all contacts regardless of subscription status. No unsubscribe footer.
</p> </p>
</button> </button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.HEADLESS)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.HEADLESS
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Headless</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to subscribed contacts only. No Plunk footer you provide the unsubscribe link.
</p>
</button>
</div> </div>
{campaignType === TemplateType.HEADLESS && !detectUnsubscribeSignal(body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
</div>
<div className="px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-800 leading-relaxed">
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
</p>
<div className="flex flex-wrap gap-1.5">
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{unsubscribeUrl}}'}
</code>
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{manageUrl}}'}
</code>
</div>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -490,7 +526,7 @@ export default function CreateCampaignPage() {
<div className="flex justify-between py-2 border-b border-neutral-100"> <div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Type</span> <span className="text-neutral-500">Type</span>
<span className="font-medium"> <span className="font-medium">
{campaignType === TemplateType.MARKETING ? 'Marketing' : 'Transactional'} {campaignType === TemplateType.MARKETING ? 'Marketing' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
</span> </span>
</div> </div>
+45 -33
View File
@@ -8,11 +8,6 @@ import {
ConfirmDialog, ConfirmDialog,
Input, Input,
Label, Label,
Select,
SelectContent,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
StickySaveBar, StickySaveBar,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Template} from '@plunk/db'; import type {Template} from '@plunk/db';
@@ -21,13 +16,13 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor'; import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking'; import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {ArrowLeft, Save, Trash2} from 'lucide-react'; import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import {TemplateSchemas} from '@plunk/shared'; import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function TemplateEditorPage() { export default function TemplateEditorPage() {
@@ -221,32 +216,49 @@ export default function TemplateEditorPage() {
</div> </div>
<div> <div>
<Label htmlFor="type">Type *</Label> <Label>Type *</Label>
<Select <div className="flex flex-col gap-2 mt-2">
value={editedTemplate.type} {([
onValueChange={value => {value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'} ,
setEditedTemplate({...editedTemplate, type: value as 'MARKETING' | 'TRANSACTIONAL'}) {value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
} {value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
> ] as const).map(({value, label, description}) => (
<SelectTrigger id="type"> <button
<SelectValue /> key={value}
</SelectTrigger> type="button"
<SelectContent> onClick={() => setEditedTemplate({...editedTemplate, type: value})}
<SelectItemWithDescription className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
value="MARKETING" editedTemplate.type === value
title="Marketing" ? 'border-neutral-900 bg-neutral-50'
description="Includes unsubscribe link, respects opt-out" : 'border-neutral-200 hover:border-neutral-300'
/> }`}
<SelectItemWithDescription >
value="TRANSACTIONAL" <span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
title="Transactional" <span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
description="For receipts, alerts - sent regardless of opt-out" </button>
/> ))}
</SelectContent> </div>
</Select> {editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
<p className="text-xs text-neutral-500 mt-1"> <div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
Marketing templates will automatically include a Plunk-hosted unsubscribe link. <div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
</p> <TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
</div>
<div className="px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-800 leading-relaxed">
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
</p>
<div className="flex flex-wrap gap-1.5">
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{unsubscribeUrl}}'}
</code>
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{manageUrl}}'}
</code>
</div>
</div>
</div>
)}
</div> </div>
<div> <div>
+60 -41
View File
@@ -7,11 +7,6 @@ import {
CardTitle, CardTitle,
Input, Input,
Label, Label,
Select,
SelectContent,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
@@ -19,12 +14,12 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor'; import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network'; import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation'; import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save} from 'lucide-react'; import {ArrowLeft, Save, TriangleAlert} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import {useRouter} from 'next/router'; import {useRouter} from 'next/router';
import {useState} from 'react'; import {useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {TemplateSchemas} from '@plunk/shared'; import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider'; import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateTemplatePage() { export default function CreateTemplatePage() {
@@ -37,7 +32,7 @@ export default function CreateTemplatePage() {
const [from, setFrom] = useState(''); const [from, setFrom] = useState('');
const [fromName, setFromName] = useState(''); const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState(''); const [replyTo, setReplyTo] = useState('');
const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL'>('MARKETING'); const [type, setType] = useState<'MARKETING' | 'TRANSACTIONAL' | 'HEADLESS'>('MARKETING');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
@@ -49,6 +44,7 @@ export default function CreateTemplatePage() {
return; return;
} }
setSaving(true); setSaving(true);
try { try {
@@ -108,41 +104,64 @@ export default function CreateTemplatePage() {
<CardDescription>Configure your template details and email settings</CardDescription> <CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div>
<div> <Label htmlFor="name">Template Name *</Label>
<Label htmlFor="name">Template Name *</Label> <Input
<Input id="name"
id="name" type="text"
type="text" value={name}
value={name} onChange={e => setName(e.target.value)}
onChange={e => setName(e.target.value)} required
required placeholder="Welcome Email"
placeholder="Welcome Email" />
/>
</div>
<div>
<Label htmlFor="type">Template Type *</Label>
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value="MARKETING"
title="Marketing"
description="Includes unsubscribe link, respects opt-out"
/>
<SelectItemWithDescription
value="TRANSACTIONAL"
title="Transactional"
description="For receipts, alerts - sent regardless of opt-out"
/>
</SelectContent>
</Select>
</div>
</div> </div>
<div>
<Label>Template Type *</Label>
<div className="flex flex-col gap-2 mt-2">
{([
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'} ,
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
] as const).map(({value, label, description}) => (
<button
key={value}
type="button"
onClick={() => setType(value)}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
type === value
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
</button>
))}
</div>
{type === 'HEADLESS' && !detectUnsubscribeSignal(body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
</div>
<div className="px-3 py-2.5 space-y-2">
<p className="text-xs text-amber-800 leading-relaxed">
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
</p>
<div className="flex flex-wrap gap-1.5">
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{unsubscribeUrl}}'}
</code>
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
{'{{manageUrl}}'}
</code>
</div>
</div>
</div>
)}
</div>
<div> <div>
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description</Label>
<Input <Input
+10 -2
View File
@@ -26,7 +26,7 @@ export default function TemplatesPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL'); const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING' | 'HEADLESS'>('ALL');
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null); const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
@@ -145,6 +145,14 @@ export default function TemplatesPage() {
> >
Transactional Transactional
</Button> </Button>
<Button
type="button"
onClick={() => setTypeFilter('HEADLESS')}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -206,7 +214,7 @@ export default function TemplatesPage() {
<CardTitle>{template.name}</CardTitle> <CardTitle>{template.name}</CardTitle>
<Badge <Badge
className={'capitalize'} className={'capitalize'}
variant={template.type === 'MARKETING' ? 'info' : 'success'} variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}
> >
{template.type.toLowerCase()} {template.type.toLowerCase()}
</Badge> </Badge>
+5 -2
View File
@@ -63,9 +63,12 @@ The subscription state controls whether a contact receives marketing emails. Tra
| Email type | Subscribed | Unsubscribed | | Email type | Subscribed | Unsubscribed |
|---|---|---| |---|---|---|
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered | | **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
| **Campaigns** | Delivered | Not delivered | | **Campaigns** (marketing) | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered | | **Campaigns** (headless) | Delivered | Not delivered |
| **Campaigns** (transactional) | Delivered | Delivered |
| **Automations** (marketing template) | Delivered | Not delivered | | **Automations** (marketing template) | Delivered | Not delivered |
| **Automations** (headless template) | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered |
<Callout <Callout
title="Transactional emails and marketing templates" title="Transactional emails and marketing templates"
@@ -51,9 +51,10 @@ You can preview your templates by selecting a contact in the preview window. Thi
## Templates types ## Templates types
There are two types of templates in Plunk. Each type is treated at the same priority when sending emails, you should not pick one type over the other based on deliverability or performance. There are three types of templates in Plunk. Each type is treated at the same priority when sending emails, you should not pick one type over the other based on deliverability or performance.
| Type | Description | | Type | Respects opt-out | Plunk unsubscribe footer | Description |
| ------------- | -------------------------------------------------------------------------------------------------------------------- | | ------------- | :--------------: | :----------------------: | -------------------------------------------------------------------------------------------------------------------- |
| Marketing | Automatically includes a Plunk-hosted unsubscribe page and footer. Will not be sent to contacts who are unsubscribed | | Marketing | Yes | Yes | Automatically includes a Plunk-hosted unsubscribe footer. Will not be sent to contacts who are unsubscribed |
| Transactional | Does not include any way to unsubscribe. Will be sent to any contact, regardless of subscription state | | Transactional | No | No | Does not include any way to unsubscribe. Will be sent to any contact, regardless of subscription state |
| Headless | Yes | No | Respects opt-out like marketing, but no Plunk footer is appended. You are responsible for providing an unsubscribe mechanism in the email body. Use `{{unsubscribeUrl}}` or `{{manageUrl}}` to link to Plunk's managed unsubscribe page |
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TemplateType" ADD VALUE 'HEADLESS';
+1
View File
@@ -647,6 +647,7 @@ enum Role {
enum TemplateType { enum TemplateType {
TRANSACTIONAL TRANSACTIONAL
MARKETING MARKETING
HEADLESS
} }
enum TrackingMode { enum TrackingMode {
+1
View File
@@ -2,3 +2,4 @@ export * from './schemas/index.js';
export * from './operators.js'; export * from './operators.js';
export * from './template.js'; export * from './template.js';
export * from './i18n/index.js'; export * from './i18n/index.js';
export * from './unsubscribe.js';
+26
View File
@@ -0,0 +1,26 @@
/**
* Detects whether an email body contains an unsubscribe signal.
*
* Used to warn authors of HEADLESS emails that no unsubscribe mechanism
* was found — Plunk cannot verify the link works, but can check for common patterns.
*
* Returns true if any of the following are present:
* - Plunk template variables: {{unsubscribeUrl}} or {{manageUrl}}
* - An <a> tag whose href contains unsubscribe-related keywords
* - An <a> tag whose visible text contains unsubscribe-related keywords
*/
export function detectUnsubscribeSignal(body: string): boolean {
if (!body) return false;
// Plunk's own managed unsubscribe variables
if (/\{\{(?:unsubscribeUrl|manageUrl)\}\}/.test(body)) return true;
// href containing unsubscribe keywords
if (/href=["'][^"']*(?:unsubscribe|opt[_-]?out|remove)[^"']*["']/i.test(body)) return true;
// Anchor text containing unsubscribe keywords
if (/<a\b[^>]*>(?:[^<]*(?:unsubscribe|opt[_-]?\s*out|manage\s+preferences|email\s+preferences|remove\s+me)[^<]*)<\/a>/i.test(body))
return true;
return false;
}