Compare commits

...
13 Commits
Author SHA1 Message Date
Dries Augustyns cbde3cce3f fix: Sync display name in TemplateSearchPicker when initialName changes 2026-04-20 19:38:20 +02:00
Doug Beatty df18979e5e extract TemplateSearchPicker to fix workflow template pagination bug 2026-04-12 10:14:34 -05:00
Doug Beatty a981a8bba4 Improve template template loading and debounced search 2026-04-12 00:33:37 -05:00
Dries Augustyns 7834b9e7ef fix: Update language validation regex to support locale variants 2026-04-05 11:42:25 +02:00
Dries Augustyns 9e2400c6de fix: Enhance email processing to support campaign types and improve unsubscribe logic 2026-04-02 14:09:01 +02:00
Dries Augustyns fb5aa8796a feat: Add headless template type 2026-04-02 12:43:27 +02:00
Dries Augustyns 284838279d test: Mock DNS lookup in integration tests for WorkflowExecutionService 2026-04-02 07:56:54 +02:00
Dries Augustyns 2c5a71518d fix: Implement SSRF protection in webhook handling with safeFetch method 2026-04-02 07:48:43 +02:00
Dries Augustyns a8014cf7cf Merge pull request #320 from hanamizuki/fix/wait-for-event-combobox 2026-04-01 18:56:00 +02:00
Dries Augustyns 3214f6c42d fix: Hint custom event names in combobox when no matches are found 2026-04-01 18:55:32 +02:00
Dries Augustyns d24259e8d2 feat: Add type to campaign 2026-04-01 18:49:51 +02:00
Dries Augustyns 3343e891bd feat: Disable projects on failed payment 2026-04-01 18:07:08 +02:00
Hana ChangandClaude Opus 4.6 3e3117e4a7 fix: replace Select/datalist with styled Combobox for event name inputs
Replace the conditional Select/Input pattern with a unified Combobox
(Input + Command dropdown) that always allows free-text input while
offering autocomplete suggestions from previously tracked events.

Applied to both workflow creation dialog and edit page (trigger event,
add WAIT_FOR_EVENT step, edit WAIT_FOR_EVENT step).

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-24 16:44:45 +08:00
31 changed files with 997 additions and 248 deletions
+5 -3
View File
@@ -1,5 +1,5 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared'; import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
@@ -20,7 +20,7 @@ export class Campaigns {
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) { private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body); CampaignSchemas.create.parse(req.body);
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) { if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
@@ -42,6 +42,7 @@ export class Campaigns {
from, from,
fromName, fromName,
replyTo, replyTo,
type,
audienceType, audienceType,
audienceCondition, audienceCondition,
segmentId, segmentId,
@@ -109,7 +110,7 @@ export class Campaigns {
private async update(req: Request, res: Response, _next: NextFunction) { private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
req.body; req.body;
// Validate audience-specific fields if audienceType is being updated // Validate audience-specific fields if audienceType is being updated
@@ -134,6 +135,7 @@ export class Campaigns {
from, from,
fromName, fromName,
replyTo, replyTo,
type: type as TemplateType | undefined,
audienceType, audienceType,
audienceCondition, audienceCondition,
segmentId, segmentId,
+41 -4
View File
@@ -5,12 +5,16 @@ import type {Request, Response} from 'express';
import signale from 'signale'; import signale from 'signale';
import type Stripe from 'stripe'; import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {BillingLimitService} from '../services/BillingLimitService.js'; import {BillingLimitService} from '../services/BillingLimitService.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.js';
import {MembershipService} from '../services/MembershipService.js';
import {MeterService} from '../services/MeterService.js'; import {MeterService} from '../services/MeterService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
@@ -519,6 +523,16 @@ export class Webhooks {
const invoice = event.data.object; const invoice = event.data.object;
const customerId = invoice.customer as string; const customerId = invoice.customer as string;
// Only disable projects that are already consuming (recurring billing).
// If billing_reason is 'subscription_create', this is a first-time payment
// attempt and the project has never had an active subscription — don't disable.
if (invoice.billing_reason === 'subscription_create') {
signale.info(
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
);
break;
}
// Find project by customer ID // Find project by customer ID
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {customer: customerId}, where: {customer: customerId},
@@ -529,10 +543,33 @@ export class Webhooks {
break; break;
} }
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`); signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
// Send notification about payment failure await prisma.project.update({
await NtfyService.notifyPaymentFailed(project.name, project.id); where: {id: project.id},
data: {disabled: true},
});
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledPaymentEmail, {
projectName: project.name,
projectId: project.id,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
);
}
} catch (emailError) {
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
}
break; break;
} }
+7 -1
View File
@@ -58,6 +58,8 @@ export async function createEmailWorker() {
include: { include: {
contact: true, contact: true,
project: true, project: true,
template: {select: {type: true}},
campaign: {select: {type: true}},
}, },
}); });
@@ -105,11 +107,15 @@ 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' &&
email.campaign?.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
+10 -2
View File
@@ -1,5 +1,5 @@
import type {Campaign, Contact, Prisma} from '@plunk/db'; import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types'; import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
@@ -59,6 +59,7 @@ export class CampaignService {
from: data.from, from: data.from,
fromName: data.fromName, fromName: data.fromName,
replyTo: data.replyTo, replyTo: data.replyTo,
type: data.type ?? TemplateType.MARKETING,
audienceType: data.audienceType, audienceType: data.audienceType,
audienceCondition: toPrismaJson(data.audienceCondition || null), audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId, segmentId: data.segmentId,
@@ -100,6 +101,10 @@ export class CampaignService {
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput; const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
// Handle campaign-specific fields // Handle campaign-specific fields
if (data.type !== undefined) {
updateData.type = data.type;
}
if (data.audienceType !== undefined) { if (data.audienceType !== undefined) {
updateData.audienceType = data.audienceType; updateData.audienceType = data.audienceType;
} }
@@ -262,6 +267,7 @@ export class CampaignService {
from: campaign.from, from: campaign.from,
fromName: campaign.fromName, fromName: campaign.fromName,
replyTo: campaign.replyTo, replyTo: campaign.replyTo,
type: campaign.type,
audienceType: campaign.audienceType, audienceType: campaign.audienceType,
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue, audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId, segmentId: campaign.segmentId,
@@ -482,6 +488,7 @@ export class CampaignService {
from: campaign.from, from: campaign.from,
fromName: campaign.fromName || undefined, fromName: campaign.fromName || undefined,
replyTo: campaign.replyTo || undefined, replyTo: campaign.replyTo || undefined,
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
}); });
} catch (error) { } catch (error) {
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error); signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
@@ -708,7 +715,8 @@ export class CampaignService {
): Promise<Prisma.ContactWhereInput> { ): Promise<Prisma.ContactWhereInput> {
const baseWhere: Prisma.ContactWhereInput = { const baseWhere: Prisma.ContactWhereInput = {
projectId, projectId,
subscribed: true, // Only send to subscribed contacts // Transactional campaigns send to all contacts regardless of subscription status
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
}; };
switch (campaign.audienceType) { switch (campaign.audienceType) {
+12 -6
View File
@@ -38,6 +38,7 @@ interface SendEmailParams {
workflowExecutionId?: string; workflowExecutionId?: string;
workflowStepExecutionId?: string; workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email) recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
isTransactional?: boolean; // Override source type to TRANSACTIONAL (e.g. for transactional campaigns)
} }
/** /**
@@ -116,10 +117,12 @@ export class EmailService {
* Send a campaign email * Send a campaign email
*/ */
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> { public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
// Check if template is transactional to determine source type // Check if campaign or template is transactional to determine source type
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN; let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
if (params.templateId) { if (params.isTransactional) {
sourceType = EmailSourceType.TRANSACTIONAL;
} else if (params.templateId) {
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: {id: params.templateId}, where: {id: params.templateId},
select: {type: true}, select: {type: true},
@@ -290,9 +293,8 @@ export class EmailService {
include: { include: {
contact: true, contact: true,
project: true, project: true,
template: { template: {select: {type: true}},
select: {type: true}, campaign: {select: {type: true}},
},
}, },
}); });
@@ -354,11 +356,15 @@ 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' &&
email.campaign?.type !== 'HEADLESS',
}); });
// Use explicit fromName if provided, otherwise fall back to project name // Use explicit fromName if provided, otherwise fall back to project name
+11
View File
@@ -170,6 +170,17 @@ export class NtfyService {
]); ]);
} }
/**
* Notify about project disabled due to payment failure
*/
public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise<void> {
await this.sendUrgent(
'Project Disabled - Payment Failed',
`Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR],
);
}
/** /**
* Notify about successful invoice payment - MIN priority (routine) * Notify about successful invoice payment - MIN priority (routine)
*/ */
@@ -10,6 +10,8 @@ import type {
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db'; import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {toPrismaJson} from '@plunk/types'; import {toPrismaJson} from '@plunk/types';
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared'; import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
import dns from 'node:dns/promises';
import net from 'node:net';
import signale from 'signale'; import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -822,6 +824,89 @@ export class WorkflowExecutionService {
}; };
} }
/**
* Validates that an IP address is not in a private/reserved range to prevent SSRF.
* Blocks loopback, private, link-local, and cloud metadata ranges.
*/
private static isPrivateIp(ip: string): boolean {
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:192.168.1.1)
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
if (net.isIPv4(addr)) {
const parts = addr.split('.').map(Number);
const a = parts[0] ?? -1;
const b = parts[1] ?? -1;
return (
a === 127 || // 127.0.0.0/8 loopback
a === 10 || // 10.0.0.0/8 private
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
(a === 192 && b === 168) || // 192.168.0.0/16 private
(a === 169 && b === 254) || // 169.254.0.0/16 link-local / cloud metadata
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space
a === 0 || // 0.0.0.0/8
a >= 224 // 224.0.0.0+ multicast and reserved
);
}
if (net.isIPv6(addr)) {
const normalized = addr.toLowerCase();
return (
normalized === '::1' || // loopback
normalized.startsWith('fe80:') || // link-local
normalized.startsWith('fc') || // unique local
normalized.startsWith('fd') || // unique local
normalized.startsWith('ff') // multicast
);
}
// Unknown format — reject to be safe
return true;
}
/**
* SSRF-safe fetch. Resolves the hostname, validates the IP is not internal,
* and manually follows redirects re-validating each hop.
*/
private static async safeFetch(url: string, options: RequestInit): Promise<Response> {
const MAX_REDIRECTS = 5;
let currentUrl = url;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
const parsed = new URL(currentUrl);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
throw new Error(`Webhook URL scheme not allowed: ${parsed.protocol}`);
}
const {address} = await dns.lookup(parsed.hostname);
if (WorkflowExecutionService.isPrivateIp(address)) {
throw new Error(`Webhook URL resolves to a private/internal IP address: ${address}`);
}
const response = await fetch(currentUrl, {
...options,
redirect: 'manual',
signal: AbortSignal.timeout(10_000),
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (!location) {
throw new Error('Redirect with no Location header');
}
// Resolve relative redirects against the current URL
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return response;
}
throw new Error('Too many redirects');
}
/** /**
* WEBHOOK step - Call an external webhook * WEBHOOK step - Call an external webhook
*/ */
@@ -859,7 +944,7 @@ export class WorkflowExecutionService {
}; };
// Make HTTP request // Make HTTP request
const response = await fetch(url, { const response = await WorkflowExecutionService.safeFetch(url, {
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -1,5 +1,5 @@
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest'; import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db'; import {EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared'; import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService'; import {EmailService} from '../EmailService';
import {sendRawEmail} from '../SESService'; import {sendRawEmail} from '../SESService';
@@ -187,6 +187,84 @@ 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 type is HEADLESS (no template)', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
// Campaign typed HEADLESS directly — no template involved (inline body)
const campaign = await factories.createCampaign({
projectId,
type: TemplateType.HEADLESS,
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
subject: 'Newsletter',
body: campaign.body,
from: '[email protected]',
});
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
expect(email.status).toBe(EmailStatus.PENDING);
});
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});
@@ -4,6 +4,12 @@ import {toPrismaJson} from '@plunk/types';
import {WorkflowExecutionService} from '../WorkflowExecutionService'; import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
vi.mock('node:dns/promises', () => ({
default: {
lookup: vi.fn(async () => ({address: '1.2.3.4', family: 4})),
},
}));
/** /**
* Integration Tests: Workflow Execution Engine * Integration Tests: Workflow Execution Engine
* *
@@ -0,0 +1,106 @@
import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {useCallback, useRef, useState} from 'react';
import useSWR from 'swr';
interface TemplateSearchPickerProps {
/** Currently selected template ID */
value: string;
/** Display name for the pre-selected template (avoids a fetch just to show the name) */
initialName?: string;
onChange: (id: string) => void;
}
/**
* Inline combobox for picking a template.
* Fires a debounced server-side search (/templates?search=…&pageSize=20)
* so it works correctly regardless of how many templates exist.
*/
export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) {
const [query, setQuery] = useState(initialName ?? '');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevInitialName = useRef(initialName);
// Sync display name when initialName changes (e.g. dialog re-opens with a different selection)
if (initialName !== prevInitialName.current) {
prevInitialName.current = initialName;
setQuery(initialName ?? '');
}
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedQuery(val), 300);
}, []);
const {data, isLoading} = useSWR<PaginatedResponse<Template>>(
open || debouncedQuery
? `/templates?pageSize=20${debouncedQuery ? `&search=${encodeURIComponent(debouncedQuery)}` : ''}`
: null,
{revalidateOnFocus: false},
);
// When closed, show the selected template's name rather than the raw query
const displayValue = open
? query
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');
return (
<div className="relative">
<Input
type="text"
value={displayValue}
onChange={handleInput}
onFocus={() => {
setOpen(true);
setDebouncedQuery(query);
}}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…"
autoComplete="off"
/>
{open && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-sm text-neutral-500">Searching</div>
) : !data?.data.length ? (
<div className="px-3 py-2 text-sm text-neutral-500">No templates found</div>
) : (
<Command>
<CommandList>
<CommandGroup>
{data.data.map(t => (
<CommandItem
key={t.id}
value={t.id}
onSelect={() => {
onChange(t.id);
setQuery(t.name);
setOpen(false);
}}
>
<span className="flex-1 truncate">{t.name}</span>
<span className="ml-2 text-xs text-neutral-400 shrink-0">{t.type}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
{(data?.total ?? 0) > 20 && (
<div className="px-3 py-1.5 text-xs text-neutral-400 border-t border-neutral-100">
Showing 20 of {data!.total} type to narrow results
</div>
)}
</div>
)}
</div>
);
}
@@ -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>
+61 -5
View File
@@ -29,8 +29,8 @@ import {
StickySaveBar, StickySaveBar,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db'; import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} 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';
@@ -216,6 +217,7 @@ export default function CampaignDetailsPage() {
from: editedCampaign.from, from: editedCampaign.from,
fromName: editedCampaign.fromName || null, fromName: editedCampaign.fromName || null,
replyTo: editedCampaign.replyTo || null, replyTo: editedCampaign.replyTo || null,
type: editedCampaign.type,
audienceType: editedCampaign.audienceType, audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined, segmentId: editedCampaign.segmentId || undefined,
}); });
@@ -232,6 +234,7 @@ export default function CampaignDetailsPage() {
from: updated.data.from, from: updated.data.from,
fromName: updated.data.fromName || '', fromName: updated.data.fromName || '',
replyTo: updated.data.replyTo || '', replyTo: updated.data.replyTo || '',
type: updated.data.type,
audienceType: updated.data.audienceType, audienceType: updated.data.audienceType,
segmentId: updated.data.segmentId || undefined, segmentId: updated.data.segmentId || undefined,
}); });
@@ -254,6 +257,7 @@ export default function CampaignDetailsPage() {
from: campaign.data.from, from: campaign.data.from,
fromName: campaign.data.fromName || '', fromName: campaign.data.fromName || '',
replyTo: campaign.data.replyTo || '', replyTo: campaign.data.replyTo || '',
type: campaign.data.type,
audienceType: campaign.data.audienceType, audienceType: campaign.data.audienceType,
segmentId: campaign.data.segmentId || undefined, segmentId: campaign.data.segmentId || undefined,
}); });
@@ -274,6 +278,7 @@ export default function CampaignDetailsPage() {
editedCampaign.from !== campaign.data.from || editedCampaign.from !== campaign.data.from ||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') || (editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') || (editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
editedCampaign.type !== campaign.data.type ||
editedCampaign.audienceType !== campaign.data.audienceType || editedCampaign.audienceType !== campaign.data.audienceType ||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null); (editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
@@ -461,6 +466,53 @@ export default function CampaignDetailsPage() {
/> />
</div> </div>
<div>
<Label>Campaign Type</Label>
<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> <div>
<Label htmlFor="subject">Subject Line *</Label> <Label htmlFor="subject">Subject Line *</Label>
<Input <Input
@@ -513,8 +565,8 @@ export default function CampaignDetailsPage() {
<SelectContent> <SelectContent>
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.ALL} value={CampaignAudienceType.ALL}
title="All Subscribed Contacts" title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description="Send to everyone who hasn't unsubscribed" description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/> />
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.SEGMENT} value={CampaignAudienceType.SEGMENT}
@@ -581,7 +633,11 @@ export default function CampaignDetailsPage() {
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" /> <Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800"> <p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts subscribe, unsubscribe, or segment membership changes. differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p> </p>
</div> </div>
</div> </div>
+101 -9
View File
@@ -15,7 +15,7 @@ import {
Textarea, Textarea,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Segment, Template} from '@plunk/db'; import type {Segment, Template} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, TemplateType} from '@plunk/db';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings'; import {EmailSettings} from '../../components/EmailSettings';
@@ -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() {
@@ -41,6 +42,7 @@ export default function CreateCampaignPage() {
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 [campaignType, setCampaignType] = useState<TemplateType>(TemplateType.MARKETING);
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL); const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState(''); const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -150,6 +152,7 @@ export default function CreateCampaignPage() {
from, from,
fromName: fromName || null, fromName: fromName || null,
replyTo: replyTo || null, replyTo: replyTo || null,
type: campaignType,
audienceType, audienceType,
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined, segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined, audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
@@ -256,11 +259,89 @@ export default function CreateCampaignPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Email Settings */} {/* Campaign Type */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader <StepHeader
stepNumber={2} stepNumber={2}
title="Campaign Type"
description="Choose how this campaign should be treated"
/>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === 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">
Sent to subscribed contacts only. Includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === 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">
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>
{/* Email Settings */}
<Card>
<CardHeader>
<StepHeader
stepNumber={3}
title="Email Settings" title="Email Settings"
description="Configure sender information and subject" description="Configure sender information and subject"
/> />
@@ -294,7 +375,7 @@ export default function CreateCampaignPage() {
{/* Email Content */} {/* Email Content */}
<Card className="overflow-visible"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" /> <StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
@@ -309,7 +390,7 @@ export default function CreateCampaignPage() {
{/* Audience Selection */} {/* Audience Selection */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader stepNumber={4} title="Audience" description="Choose who will receive this campaign" /> <StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" />
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -327,8 +408,8 @@ export default function CreateCampaignPage() {
<SelectContent> <SelectContent>
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.ALL} value={CampaignAudienceType.ALL}
title="All Subscribed Contacts" title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description="Send to everyone who hasn't unsubscribed" description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/> />
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.SEGMENT} value={CampaignAudienceType.SEGMENT}
@@ -388,9 +469,13 @@ export default function CreateCampaignPage() {
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3"> <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" /> <Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p> <p className="text-sm font-medium text-blue-900">
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
</p>
<p className="text-xs text-blue-700 mt-1"> <p className="text-xs text-blue-700 mt-1">
This campaign will be sent to all contacts who haven&#39;t unsubscribed {campaignType === TemplateType.TRANSACTIONAL
? 'This campaign will be sent to all contacts regardless of subscription status'
: "This campaign will be sent to all contacts who haven't unsubscribed"}
</p> </p>
</div> </div>
</div> </div>
@@ -438,6 +523,13 @@ export default function CreateCampaignPage() {
</div> </div>
)} )}
<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' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
</span>
</div>
<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">Audience</span> <span className="text-neutral-500">Audience</span>
<span className="font-medium"> <span className="font-medium">
+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>
+135 -101
View File
@@ -26,6 +26,10 @@ import {
SelectItemWithDescription, SelectItemWithDescription,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Command,
CommandGroup,
CommandItem,
CommandList,
Switch, Switch,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db'; import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
@@ -56,6 +60,7 @@ import {useEffect, useState} from 'react';
import {toast} from 'sonner'; import {toast} from 'sonner';
import useSWR from 'swr'; import useSWR from 'swr';
import {WorkflowBuilder} from '../../components/WorkflowBuilder'; import {WorkflowBuilder} from '../../components/WorkflowBuilder';
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
import {ReactFlowProvider} from '@xyflow/react'; import {ReactFlowProvider} from '@xyflow/react';
import {WorkflowSchemas} from '@plunk/shared'; import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -793,6 +798,7 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
const [description, setDescription] = useState(workflow.description ?? ''); const [description, setDescription] = useState(workflow.description ?? '');
const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false); const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false);
const [eventName, setEventName] = useState(triggerConfig?.eventName ?? ''); const [eventName, setEventName] = useState(triggerConfig?.eventName ?? '');
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Sync state when workflow changes or dialog opens // Sync state when workflow changes or dialog opens
@@ -852,29 +858,50 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
<div> <div>
<Label htmlFor="eventName">Trigger Event *</Label> <Label htmlFor="eventName">Trigger Event *</Label>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( <div className="relative">
<Select value={eventName} onValueChange={setEventName} required>
<SelectTrigger id="eventName">
<SelectValue placeholder="Select an event" />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
id="eventName" id="eventName"
type="text" type="text"
value={eventName} value={eventName}
onChange={e => setEventName(e.target.value)} onChange={e => {
setEventName(e.target.value);
setEventPopoverOpen(true);
}}
onFocus={() => setEventPopoverOpen(true)}
onBlur={() => {
setTimeout(() => setEventPopoverOpen(false), 150);
}}
placeholder="e.g., contact.created, email.opened" placeholder="e.g., contact.created, email.opened"
required required
autoComplete="off"
/> />
)} {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName?.trim()) && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md">
<Command>
<CommandList>
<CommandGroup>
{eventNamesData?.eventNames
?.filter(n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()))
.map(n => (
<CommandItem key={n} value={n} onSelect={() => { setEventName(n); setEventPopoverOpen(false); }}>
{n}
</CommandItem>
))}
{eventName?.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()) && (
<CommandItem
key="__custom__"
value={eventName.trim()}
onSelect={() => { setEventName(eventName.trim()); setEventPopoverOpen(false); }}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
<p className="text-xs text-neutral-500 mt-1"> <p className="text-xs text-neutral-500 mt-1">
The event that triggers this workflow to start for a contact The event that triggers this workflow to start for a contact
</p> </p>
@@ -961,6 +988,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
// WAIT_FOR_EVENT fields // WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(''); const [eventName, setEventName] = useState('');
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1'); const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1');
const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days'); const [eventTimeoutUnit, setEventTimeoutUnit] = useState<'minutes' | 'hours' | 'days'>('days');
@@ -977,7 +1005,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100'); // templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null); const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);
// Fetch available event names when dialog opens // Fetch available event names when dialog opens
@@ -1313,21 +1341,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<Label htmlFor="template" className="text-sm font-medium"> <Label htmlFor="template" className="text-sm font-medium">
Email Template * Email Template *
</Label> </Label>
<Select value={templateId} onValueChange={setTemplateId} required> <TemplateSearchPicker value={templateId} onChange={setTemplateId} />
<SelectTrigger id="template" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p> <p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div> </div>
@@ -1611,34 +1625,53 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<Label htmlFor="eventName" className="text-sm font-medium"> <Label htmlFor="eventName" className="text-sm font-medium">
Event Name * Event Name *
</Label> </Label>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( <div className="relative">
<Select value={eventName} onValueChange={setEventName} required>
<SelectTrigger id="eventName" className="mt-1.5">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
id="eventName" id="eventName"
type="text" type="text"
value={eventName} value={eventName}
onChange={e => setEventName(e.target.value)} onChange={e => {
setEventName(e.target.value);
setEventPopoverOpen(true);
}}
onFocus={() => setEventPopoverOpen(true)}
onBlur={() => {
setTimeout(() => setEventPopoverOpen(false), 150);
}}
required required
placeholder="e.g., email.clicked, user.upgraded" placeholder="e.g., email.clicked, user.upgraded"
className="mt-1.5" className="mt-1.5"
autoComplete="off"
/> />
)} {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName?.trim()) && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md">
<Command>
<CommandList>
<CommandGroup>
{eventNamesData?.eventNames
?.filter(n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()))
.map(n => (
<CommandItem key={n} value={n} onSelect={() => { setEventName(n); setEventPopoverOpen(false); }}>
{n}
</CommandItem>
))}
{eventName?.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()) && (
<CommandItem
key="__custom__"
value={eventName.trim()}
onSelect={() => { setEventName(eventName.trim()); setEventPopoverOpen(false); }}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
<p className="text-xs text-neutral-500 mt-1.5"> <p className="text-xs text-neutral-500 mt-1.5">
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 Enter the event name to wait for, or select from previously tracked events
? 'The workflow will pause until this event occurs'
: 'Enter the event name to wait for'}
</p> </p>
</div> </div>
@@ -1991,6 +2024,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// WAIT_FOR_EVENT fields // WAIT_FOR_EVENT fields
const [eventName, setEventName] = useState(String(config?.eventName || '')); const [eventName, setEventName] = useState(String(config?.eventName || ''));
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
const [eventTimeoutAmount, setEventTimeoutAmount] = useState<string>(() => { const [eventTimeoutAmount, setEventTimeoutAmount] = useState<string>(() => {
const timeoutSeconds = Number(config?.timeout) || 86400; const timeoutSeconds = Number(config?.timeout) || 86400;
// Convert seconds to most appropriate unit // Convert seconds to most appropriate unit
@@ -2026,7 +2060,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// EXIT fields // EXIT fields
const [exitReason, setExitReason] = useState(String(config?.reason || 'completed')); const [exitReason, setExitReason] = useState(String(config?.reason || 'completed'));
const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100'); // templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null); const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);
// Fetch available event names when dialog opens // Fetch available event names when dialog opens
@@ -2312,21 +2346,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<Label htmlFor="editTemplate" className="text-sm font-medium"> <Label htmlFor="editTemplate" className="text-sm font-medium">
Email Template * Email Template *
</Label> </Label>
<Select value={templateId} onValueChange={setTemplateId} required> <TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
<SelectTrigger id="editTemplate" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p> <p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div> </div>
@@ -2793,40 +2813,54 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<div className="space-y-4 pl-3"> <div className="space-y-4 pl-3">
<div> <div>
<Label htmlFor="editEventName">Event Name *</Label> <Label htmlFor="editEventName">Event Name *</Label>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( <div className="relative">
<> <Input
<Select value={eventName} onValueChange={setEventName} required> id="editEventName"
<SelectTrigger id="editEventName" className="mt-1.5"> type="text"
<SelectValue placeholder="Select an event..." /> value={eventName}
</SelectTrigger> onChange={e => {
<SelectContent> setEventName(e.target.value);
{eventNamesData.eventNames.map(name => ( setEventPopoverOpen(true);
<SelectItem key={name} value={name}> }}
{name} onFocus={() => setEventPopoverOpen(true)}
</SelectItem> onBlur={() => {
))} setTimeout(() => setEventPopoverOpen(false), 150);
</SelectContent> }}
</Select> required
<p className="text-xs text-neutral-500 mt-1.5"> placeholder="e.g., email.clicked, user.upgraded"
Select from previously tracked events in your project className="mt-1.5"
</p> autoComplete="off"
</> />
) : ( {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName?.trim()) && (
<> <div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md">
<Input <Command>
id="editEventName" <CommandList>
type="text" <CommandGroup>
value={eventName} {eventNamesData?.eventNames
onChange={e => setEventName(e.target.value)} ?.filter(n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()))
required .map(n => (
placeholder="e.g., email.clicked, user.upgraded" <CommandItem key={n} value={n} onSelect={() => { setEventName(n); setEventPopoverOpen(false); }}>
className="mt-1.5" {n}
/> </CommandItem>
<p className="text-xs text-neutral-500 mt-1.5"> ))}
The workflow will pause until this event is triggered by the contact {eventName?.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()) && (
</p> <CommandItem
</> key="__custom__"
)} value={eventName.trim()}
onSelect={() => { setEventName(eventName.trim()); setEventPopoverOpen(false); }}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
<p className="text-xs text-neutral-500 mt-1.5">
Enter the event name to wait for, or select from previously tracked events
</p>
</div> </div>
<div> <div>
+58 -27
View File
@@ -6,6 +6,10 @@ import {
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
Command,
CommandGroup,
CommandItem,
CommandList,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,11 +18,6 @@ import {
DialogTitle, DialogTitle,
Input, Input,
Label, Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Workflow} from '@plunk/db'; import type {Workflow} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
@@ -321,6 +320,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [eventName, setEventName] = useState(''); const [eventName, setEventName] = useState('');
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
const [allowReentry, setAllowReentry] = useState(false); const [allowReentry, setAllowReentry] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -391,34 +391,65 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
</div> </div>
<div> <div>
<Label htmlFor="eventName">Trigger Event *</Label> <Label htmlFor="createEventName">Trigger Event *</Label>
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( {/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */}
<Select value={eventName} onValueChange={setEventName} required> <div className="relative">
<SelectTrigger id="eventName">
<SelectValue placeholder="Select an event..." />
</SelectTrigger>
<SelectContent>
{eventNamesData.eventNames.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input <Input
id="eventName" id="createEventName"
type="text" type="text"
value={eventName} value={eventName}
onChange={e => setEventName(e.target.value)} onChange={e => {
required setEventName(e.target.value);
setEventPopoverOpen(true);
}}
onFocus={() => setEventPopoverOpen(true)}
onBlur={() => {
// 延遲關閉,讓 CommandItem 的 onSelect 有時間觸發
setTimeout(() => setEventPopoverOpen(false), 150);
}}
placeholder="e.g., contact.created, email.opened" placeholder="e.g., contact.created, email.opened"
required
autoComplete="off"
/> />
)} {eventPopoverOpen && ((eventNamesData?.eventNames?.length ?? 0) > 0 || eventName?.trim()) && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md">
<Command>
<CommandList>
<CommandGroup>
{eventNamesData?.eventNames
?.filter(n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()))
.map(n => (
<CommandItem
key={n}
value={n}
onSelect={() => {
setEventName(n);
setEventPopoverOpen(false);
}}
>
{n}
</CommandItem>
))}
{eventName?.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim()) && (
<CommandItem
key="__custom__"
value={eventName.trim()}
onSelect={() => {
setEventName(eventName.trim());
setEventPopoverOpen(false);
}}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
<p className="text-xs text-neutral-500 mt-1"> <p className="text-xs text-neutral-500 mt-1">
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 The event that triggers this workflow to start for a contact
? 'Select from previously tracked events'
: 'No events tracked yet. Enter the event name that will trigger this workflow.'}
</p> </p>
</div> </div>
+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 @@
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'MARKETING';
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TemplateType" ADD VALUE 'HEADLESS';
+4
View File
@@ -291,6 +291,9 @@ model Campaign {
fromName String? fromName String?
replyTo String? replyTo String?
// Campaign type
type TemplateType @default(MARKETING)
// Audience selection // Audience selection
audienceType CampaignAudienceType @default(ALL) audienceType CampaignAudienceType @default(ALL)
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition) audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
@@ -644,6 +647,7 @@ enum Role {
enum TemplateType { enum TemplateType {
TRANSACTIONAL TRANSACTIONAL
MARKETING MARKETING
HEADLESS
} }
enum TrackingMode { enum TrackingMode {
@@ -0,0 +1,78 @@
import {Heading, Link, Section, Text} from '@react-email/components';
import * as React from 'react';
import {EmailLayout} from '../common/EmailLayout';
import {Footer} from '../common/Footer';
import {Header} from '../common/Header';
interface ProjectDisabledPaymentEmailProps {
projectName: string;
projectId: string;
dashboardUrl?: string;
landingUrl?: string;
}
export function ProjectDisabledPaymentEmail({
projectName = 'My Project',
projectId = 'proj_example123',
dashboardUrl = 'https://next-app.useplunk.com',
landingUrl = 'https://www.useplunk.com',
}: ProjectDisabledPaymentEmailProps) {
return (
<EmailLayout>
<Header />
<Section className="px-8 pb-10 pt-10">
<Heading className="mb-2 mt-0 text-2xl font-semibold tracking-tight text-gray-900">Project disabled</Heading>
<Text className="mb-8 mt-0 text-base leading-relaxed text-gray-600">
Your project <strong className="font-medium text-gray-900">{projectName}</strong> has been automatically
disabled because a recurring payment could not be processed.
</Text>
<Section className="mb-8 rounded-lg bg-red-50 px-6 py-4" style={{border: '1px solid #fca5a5'}}>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-red-900">
All email sending is currently blocked. Please update your payment method to re-enable the project.
</Text>
</Section>
<Heading className="mb-4 mt-0 text-lg font-semibold text-gray-900">How to restore your project</Heading>
<Section className="mb-8">
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Update your payment method</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Go to your billing settings and add a valid payment method
</Text>
</Section>
<Section className="mb-3">
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Re-enable your project</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Once your payment is resolved, contact support to re-enable your project
</Text>
</Section>
<Section>
<Text className="mb-1 mt-0 text-sm font-medium text-gray-900">Contact support</Text>
<Text className="mb-0 mt-0 text-sm leading-relaxed text-gray-600">
Need help? Our team is available to assist you
</Text>
</Section>
</Section>
<Section className="mb-6">
<Link
href={`${dashboardUrl}/settings?tab=billing`}
className="inline-block rounded-md bg-gray-900 px-6 py-3 text-sm font-medium text-white no-underline"
>
Update payment method
</Link>
</Section>
</Section>
<Footer projectId={projectId} landingUrl={landingUrl} />
</EmailLayout>
);
}
export default ProjectDisabledPaymentEmail;
+1
View File
@@ -1,4 +1,5 @@
export {ProjectDisabledEmail} from './ProjectDisabled'; export {ProjectDisabledEmail} from './ProjectDisabled';
export {ProjectDisabledPaymentEmail} from './ProjectDisabledPayment';
export {BillingLimitWarningEmail} from './BillingLimitWarning'; export {BillingLimitWarningEmail} from './BillingLimitWarning';
export {BillingLimitExceededEmail} from './BillingLimitExceeded'; export {BillingLimitExceededEmail} from './BillingLimitExceeded';
export {EmailVerificationEmail} from './EmailVerification'; export {EmailVerificationEmail} from './EmailVerification';
+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';
+3 -2
View File
@@ -70,8 +70,7 @@ export const ProjectSchemas = {
tracking: z.nativeEnum(TrackingMode).optional(), tracking: z.nativeEnum(TrackingMode).optional(),
language: z language: z
.string() .string()
.length(2) .regex(/^[a-z]{2}(-[A-Z]{2})?$/)
.regex(/^[a-z]{2}$/)
.optional(), .optional(),
}), }),
} as const; } as const;
@@ -349,6 +348,7 @@ export const CampaignSchemas = {
from: email, from: email,
fromName: z.string().max(100).nullish(), fromName: z.string().max(100).nullish(),
replyTo: email.nullish(), replyTo: email.nullish(),
type: z.nativeEnum(TemplateType).default(TemplateType.MARKETING),
audienceType: z.nativeEnum(CampaignAudienceType), audienceType: z.nativeEnum(CampaignAudienceType),
audienceCondition: filterConditionSchema.optional(), audienceCondition: filterConditionSchema.optional(),
segmentId: uuid.optional(), segmentId: uuid.optional(),
@@ -364,6 +364,7 @@ export const CampaignSchemas = {
from: z.string().optional(), from: z.string().optional(),
fromName: z.string().max(100).nullish(), fromName: z.string().max(100).nullish(),
replyTo: z.string().nullish(), replyTo: z.string().nullish(),
type: z.nativeEnum(TemplateType).optional(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(), audienceType: z.nativeEnum(CampaignAudienceType).optional(),
audienceCondition: filterConditionSchema.optional(), audienceCondition: filterConditionSchema.optional(),
segmentId: z.string().optional(), segmentId: z.string().optional(),
+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;
}
+3 -1
View File
@@ -2,7 +2,7 @@
* Campaign service types * Campaign service types
*/ */
import type {CampaignAudienceType} from '@plunk/db'; import type {CampaignAudienceType, TemplateType} from '@plunk/db';
import type {FilterCondition} from '../segments/index.js'; import type {FilterCondition} from '../segments/index.js';
/** /**
@@ -16,6 +16,7 @@ export interface CreateCampaignData {
from: string; from: string;
fromName?: string | null; fromName?: string | null;
replyTo?: string | null; replyTo?: string | null;
type?: TemplateType;
audienceType: CampaignAudienceType; audienceType: CampaignAudienceType;
audienceCondition?: FilterCondition; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;
@@ -32,6 +33,7 @@ export interface UpdateCampaignData {
from?: string; from?: string;
fromName?: string | null; fromName?: string | null;
replyTo?: string | null; replyTo?: string | null;
type?: TemplateType;
audienceType?: CampaignAudienceType; audienceType?: CampaignAudienceType;
audienceCondition?: FilterCondition; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;
+2
View File
@@ -67,6 +67,7 @@ export interface CampaignFactoryOptions {
status?: CampaignStatus; status?: CampaignStatus;
scheduledFor?: Date | null; scheduledFor?: Date | null;
segmentId?: string | null; segmentId?: string | null;
type?: TemplateType;
} }
export interface WorkflowFactoryOptions { export interface WorkflowFactoryOptions {
@@ -224,6 +225,7 @@ export class TestFactories {
status: options.status || CampaignStatus.DRAFT, status: options.status || CampaignStatus.DRAFT,
scheduledFor: options.scheduledFor, scheduledFor: options.scheduledFor,
segmentId: options.segmentId, segmentId: options.segmentId,
type: options.type || TemplateType.MARKETING,
}, },
}); });
} }