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: {
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
+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: '[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', () => {
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});
@@ -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<Template | null>(null);
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
@@ -186,6 +186,17 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
>
Transactional
</Button>
<Button
type="button"
onClick={() => {
setTypeFilter('HEADLESS');
setPage(1);
}}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div>
</div>
@@ -233,7 +244,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<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()}
</Badge>
</div>
+46 -31
View File
@@ -30,7 +30,7 @@ import {
} from '@plunk/ui';
import type {Campaign, Segment} 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 {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
@@ -49,6 +49,7 @@ import {
TestTube,
Trash2,
TrendingUp,
TriangleAlert,
Users,
XCircle,
} from 'lucide-react';
@@ -467,36 +468,49 @@ export default function CampaignDetailsPage() {
<div>
<Label>Campaign Type</Label>
<div className="grid grid-cols-2 gap-3 mt-2">
<button
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.MARKETING})}
className={`text-left p-3 rounded-lg border-2 transition-colors ${
(editedCampaign.type ?? c.type) === TemplateType.MARKETING
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Marketing</p>
<p className="text-xs text-neutral-500 mt-1">
Subscribed contacts only, includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.TRANSACTIONAL})}
className={`text-left p-3 rounded-lg border-2 transition-colors ${
(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 className="flex flex-col gap-2 mt-2">
{([
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
] as const).map(({value, label, description}) => (
<button
key={value}
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
(editedCampaign.type ?? c.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>
{(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>
@@ -622,7 +636,8 @@ export default function CampaignDetailsPage() {
differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'}
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p>
</div>
</div>
+39 -3
View File
@@ -23,12 +23,13 @@ import {EmailEditor} from '../../components/EmailEditor';
import {StepHeader} from '../../components/StepHeader';
import {network} from '../../lib/network';
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 {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateCampaignPage() {
@@ -268,7 +269,7 @@ export default function CreateCampaignPage() {
/>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)}
@@ -297,7 +298,42 @@ export default function CreateCampaignPage() {
Sent to all contacts regardless of subscription status. No unsubscribe footer.
</p>
</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>
{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>
</Card>
@@ -490,7 +526,7 @@ export default function CreateCampaignPage() {
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Type</span>
<span className="font-medium">
{campaignType === TemplateType.MARKETING ? 'Marketing' : 'Transactional'}
{campaignType === TemplateType.MARKETING ? 'Marketing' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
</span>
</div>
+45 -33
View File
@@ -8,11 +8,6 @@ import {
ConfirmDialog,
Input,
Label,
Select,
SelectContent,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
StickySaveBar,
} from '@plunk/ui';
import type {Template} from '@plunk/db';
@@ -21,13 +16,13 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
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 {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {TemplateSchemas} from '@plunk/shared';
import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function TemplateEditorPage() {
@@ -221,32 +216,49 @@ export default function TemplateEditorPage() {
</div>
<div>
<Label htmlFor="type">Type *</Label>
<Select
value={editedTemplate.type}
onValueChange={value =>
setEditedTemplate({...editedTemplate, type: 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>
<p className="text-xs text-neutral-500 mt-1">
Marketing templates will automatically include a Plunk-hosted unsubscribe link.
</p>
<Label>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={() => setEditedTemplate({...editedTemplate, type: value})}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
editedTemplate.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>
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
<div className="mt-2 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>
+60 -41
View File
@@ -7,11 +7,6 @@ import {
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -19,12 +14,12 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, Save} from 'lucide-react';
import {ArrowLeft, Save, TriangleAlert} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
import {toast} from 'sonner';
import {TemplateSchemas} from '@plunk/shared';
import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
export default function CreateTemplatePage() {
@@ -37,7 +32,7 @@ export default function CreateTemplatePage() {
const [from, setFrom] = useState('');
const [fromName, setFromName] = 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 handleSubmit = async (e: React.FormEvent) => {
@@ -49,6 +44,7 @@ export default function CreateTemplatePage() {
return;
}
setSaving(true);
try {
@@ -108,41 +104,64 @@ export default function CreateTemplatePage() {
<CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
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>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</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>
<Label htmlFor="description">Description</Label>
<Input
+10 -2
View File
@@ -26,7 +26,7 @@ export default function TemplatesPage() {
const [page, setPage] = useState(1);
const [search, setSearch] = 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 [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
@@ -145,6 +145,14 @@ export default function TemplatesPage() {
>
Transactional
</Button>
<Button
type="button"
onClick={() => setTypeFilter('HEADLESS')}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div>
</form>
</CardContent>
@@ -206,7 +214,7 @@ export default function TemplatesPage() {
<CardTitle>{template.name}</CardTitle>
<Badge
className={'capitalize'}
variant={template.type === 'MARKETING' ? 'info' : 'success'}
variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}
>
{template.type.toLowerCase()}
</Badge>
+5 -2
View File
@@ -63,9 +63,12 @@ The subscription state controls whether a contact receives marketing emails. Tra
| Email type | Subscribed | Unsubscribed |
|---|---|---|
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
| **Campaigns** | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered |
| **Campaigns** (marketing) | Delivered | Not delivered |
| **Campaigns** (headless) | Delivered | Not delivered |
| **Campaigns** (transactional) | Delivered | Delivered |
| **Automations** (marketing template) | Delivered | Not delivered |
| **Automations** (headless template) | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered |
<Callout
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
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 |
| ------------- | -------------------------------------------------------------------------------------------------------------------- |
| Marketing | Automatically includes a Plunk-hosted unsubscribe page and 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 |
| Type | Respects opt-out | Plunk unsubscribe footer | Description |
| ------------- | :--------------: | :----------------------: | -------------------------------------------------------------------------------------------------------------------- |
| Marketing | Yes | Yes | Automatically includes a Plunk-hosted unsubscribe footer. Will not be sent to contacts who are unsubscribed |
| 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 |