Compare commits
13
Commits
v0.8.0
...
wcatbb/next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbde3cce3f | ||
|
|
df18979e5e | ||
|
|
a981a8bba4 | ||
|
|
7834b9e7ef | ||
|
|
9e2400c6de | ||
|
|
fb5aa8796a | ||
|
|
284838279d | ||
|
|
2c5a71518d | ||
|
|
a8014cf7cf | ||
|
|
3214f6c42d | ||
|
|
d24259e8d2 | ||
|
|
3343e891bd | ||
|
|
3e3117e4a7 |
@@ -1,5 +1,5 @@
|
||||
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 type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
@@ -20,7 +20,7 @@ export class Campaigns {
|
||||
@CatchAsync
|
||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||
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);
|
||||
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||
@@ -42,6 +42,7 @@ export class Campaigns {
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
@@ -109,7 +110,7 @@ export class Campaigns {
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
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;
|
||||
|
||||
// Validate audience-specific fields if audienceType is being updated
|
||||
@@ -134,6 +135,7 @@ export class Campaigns {
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type: type as TemplateType | undefined,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
|
||||
@@ -5,12 +5,16 @@ import type {Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
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 {prisma} from '../database/prisma.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
@@ -519,6 +523,16 @@ export class Webhooks {
|
||||
const invoice = event.data.object;
|
||||
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
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {customer: customerId},
|
||||
@@ -529,10 +543,33 @@ export class Webhooks {
|
||||
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 NtfyService.notifyPaymentFailed(project.name, project.id);
|
||||
await prisma.project.update({
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ export async function createEmailWorker() {
|
||||
include: {
|
||||
contact: 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
|
||||
// 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' &&
|
||||
email.campaign?.type !== 'HEADLESS',
|
||||
});
|
||||
|
||||
// Use fromName from database if available, otherwise fall back to project name
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
@@ -59,6 +59,7 @@ export class CampaignService {
|
||||
from: data.from,
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
type: data.type ?? TemplateType.MARKETING,
|
||||
audienceType: data.audienceType,
|
||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||
segmentId: data.segmentId,
|
||||
@@ -100,6 +101,10 @@ export class CampaignService {
|
||||
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
||||
|
||||
// Handle campaign-specific fields
|
||||
if (data.type !== undefined) {
|
||||
updateData.type = data.type;
|
||||
}
|
||||
|
||||
if (data.audienceType !== undefined) {
|
||||
updateData.audienceType = data.audienceType;
|
||||
}
|
||||
@@ -262,6 +267,7 @@ export class CampaignService {
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName,
|
||||
replyTo: campaign.replyTo,
|
||||
type: campaign.type,
|
||||
audienceType: campaign.audienceType,
|
||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||
segmentId: campaign.segmentId,
|
||||
@@ -482,6 +488,7 @@ export class CampaignService {
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName || undefined,
|
||||
replyTo: campaign.replyTo || undefined,
|
||||
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
@@ -708,7 +715,8 @@ export class CampaignService {
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const baseWhere: Prisma.ContactWhereInput = {
|
||||
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) {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface SendEmailParams {
|
||||
workflowExecutionId?: string;
|
||||
workflowStepExecutionId?: string;
|
||||
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
|
||||
*/
|
||||
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;
|
||||
|
||||
if (params.templateId) {
|
||||
if (params.isTransactional) {
|
||||
sourceType = EmailSourceType.TRANSACTIONAL;
|
||||
} else if (params.templateId) {
|
||||
const template = await prisma.template.findUnique({
|
||||
where: {id: params.templateId},
|
||||
select: {type: true},
|
||||
@@ -290,9 +293,8 @@ export class EmailService {
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
template: {
|
||||
select: {type: true},
|
||||
},
|
||||
template: {select: {type: true}},
|
||||
campaign: {select: {type: true}},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -354,11 +356,15 @@ 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' &&
|
||||
email.campaign?.type !== 'HEADLESS',
|
||||
});
|
||||
|
||||
// Use explicit fromName if provided, otherwise fall back to project name
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
||||
import dns from 'node:dns/promises';
|
||||
import net from 'node:net';
|
||||
import signale from 'signale';
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -859,7 +944,7 @@ export class WorkflowExecutionService {
|
||||
};
|
||||
|
||||
// Make HTTP request
|
||||
const response = await fetch(url, {
|
||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {EmailService} from '../EmailService';
|
||||
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', () => {
|
||||
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});
|
||||
|
||||
|
||||
@@ -4,6 +4,12 @@ import {toPrismaJson} from '@plunk/types';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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 [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>
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
StickySaveBar,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign, Segment} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignSchemas} from '@plunk/shared';
|
||||
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||
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';
|
||||
@@ -216,6 +217,7 @@ export default function CampaignDetailsPage() {
|
||||
from: editedCampaign.from,
|
||||
fromName: editedCampaign.fromName || null,
|
||||
replyTo: editedCampaign.replyTo || null,
|
||||
type: editedCampaign.type,
|
||||
audienceType: editedCampaign.audienceType,
|
||||
segmentId: editedCampaign.segmentId || undefined,
|
||||
});
|
||||
@@ -232,6 +234,7 @@ export default function CampaignDetailsPage() {
|
||||
from: updated.data.from,
|
||||
fromName: updated.data.fromName || '',
|
||||
replyTo: updated.data.replyTo || '',
|
||||
type: updated.data.type,
|
||||
audienceType: updated.data.audienceType,
|
||||
segmentId: updated.data.segmentId || undefined,
|
||||
});
|
||||
@@ -254,6 +257,7 @@ export default function CampaignDetailsPage() {
|
||||
from: campaign.data.from,
|
||||
fromName: campaign.data.fromName || '',
|
||||
replyTo: campaign.data.replyTo || '',
|
||||
type: campaign.data.type,
|
||||
audienceType: campaign.data.audienceType,
|
||||
segmentId: campaign.data.segmentId || undefined,
|
||||
});
|
||||
@@ -274,6 +278,7 @@ export default function CampaignDetailsPage() {
|
||||
editedCampaign.from !== campaign.data.from ||
|
||||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
|
||||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
|
||||
editedCampaign.type !== campaign.data.type ||
|
||||
editedCampaign.audienceType !== campaign.data.audienceType ||
|
||||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
|
||||
|
||||
@@ -461,6 +466,53 @@ export default function CampaignDetailsPage() {
|
||||
/>
|
||||
</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>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
@@ -513,8 +565,8 @@ export default function CampaignDetailsPage() {
|
||||
<SelectContent>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.ALL}
|
||||
title="All Subscribed Contacts"
|
||||
description="Send to everyone who hasn't unsubscribed"
|
||||
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
|
||||
/>
|
||||
<SelectItemWithDescription
|
||||
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" />
|
||||
<p className="text-xs text-blue-800">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Textarea,
|
||||
} from '@plunk/ui';
|
||||
import type {Segment, Template} from '@plunk/db';
|
||||
import {CampaignAudienceType} from '@plunk/db';
|
||||
import {CampaignAudienceType, TemplateType} from '@plunk/db';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {EmailSettings} from '../../components/EmailSettings';
|
||||
@@ -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() {
|
||||
@@ -41,6 +42,7 @@ export default function CreateCampaignPage() {
|
||||
const [from, setFrom] = useState('');
|
||||
const [fromName, setFromName] = useState('');
|
||||
const [replyTo, setReplyTo] = useState('');
|
||||
const [campaignType, setCampaignType] = useState<TemplateType>(TemplateType.MARKETING);
|
||||
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
|
||||
const [segmentId, setSegmentId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -150,6 +152,7 @@ export default function CreateCampaignPage() {
|
||||
from,
|
||||
fromName: fromName || null,
|
||||
replyTo: replyTo || null,
|
||||
type: campaignType,
|
||||
audienceType,
|
||||
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
|
||||
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
|
||||
@@ -256,11 +259,89 @@ export default function CreateCampaignPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Settings */}
|
||||
{/* Campaign Type */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<StepHeader
|
||||
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"
|
||||
description="Configure sender information and subject"
|
||||
/>
|
||||
@@ -294,7 +375,7 @@ export default function CreateCampaignPage() {
|
||||
{/* Email Content */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
|
||||
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
@@ -309,7 +390,7 @@ export default function CreateCampaignPage() {
|
||||
{/* Audience Selection */}
|
||||
<Card>
|
||||
<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>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -327,8 +408,8 @@ export default function CreateCampaignPage() {
|
||||
<SelectContent>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.ALL}
|
||||
title="All Subscribed Contacts"
|
||||
description="Send to everyone who hasn't unsubscribed"
|
||||
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
|
||||
description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
|
||||
/>
|
||||
<SelectItemWithDescription
|
||||
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">
|
||||
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<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">
|
||||
This campaign will be sent to all contacts who haven'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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -438,6 +523,13 @@ export default function CreateCampaignPage() {
|
||||
</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">
|
||||
<span className="text-neutral-500">Audience</span>
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
SelectItemWithDescription,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
Switch,
|
||||
} from '@plunk/ui';
|
||||
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
|
||||
@@ -56,6 +60,7 @@ import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
|
||||
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
|
||||
import {ReactFlowProvider} from '@xyflow/react';
|
||||
import {WorkflowSchemas} from '@plunk/shared';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -793,6 +798,7 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
|
||||
const [description, setDescription] = useState(workflow.description ?? '');
|
||||
const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false);
|
||||
const [eventName, setEventName] = useState(triggerConfig?.eventName ?? '');
|
||||
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Sync state when workflow changes or dialog opens
|
||||
@@ -852,29 +858,50 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
|
||||
|
||||
<div>
|
||||
<Label htmlFor="eventName">Trigger Event *</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="eventName"
|
||||
type="text"
|
||||
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"
|
||||
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 “{eventName.trim()}”
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The event that triggers this workflow to start for a contact
|
||||
</p>
|
||||
@@ -961,6 +988,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
|
||||
|
||||
// WAIT_FOR_EVENT fields
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
|
||||
const [eventTimeoutAmount, setEventTimeoutAmount] = useState('1');
|
||||
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 {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100');
|
||||
// templates fetched on-demand by TemplateSearchPicker
|
||||
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);
|
||||
|
||||
// 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">
|
||||
Email Template *
|
||||
</Label>
|
||||
<Select value={templateId} onValueChange={setTemplateId} required>
|
||||
<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>
|
||||
<TemplateSearchPicker value={templateId} onChange={setTemplateId} />
|
||||
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
|
||||
</div>
|
||||
|
||||
@@ -1611,34 +1625,53 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
|
||||
<Label htmlFor="eventName" className="text-sm font-medium">
|
||||
Event Name *
|
||||
</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="eventName"
|
||||
type="text"
|
||||
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
|
||||
placeholder="e.g., email.clicked, user.upgraded"
|
||||
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 “{eventName.trim()}”
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
|
||||
? 'The workflow will pause until this event occurs'
|
||||
: 'Enter the event name to wait for'}
|
||||
Enter the event name to wait for, or select from previously tracked events
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1991,6 +2024,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
|
||||
// WAIT_FOR_EVENT fields
|
||||
const [eventName, setEventName] = useState(String(config?.eventName || ''));
|
||||
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
|
||||
const [eventTimeoutAmount, setEventTimeoutAmount] = useState<string>(() => {
|
||||
const timeoutSeconds = Number(config?.timeout) || 86400;
|
||||
// Convert seconds to most appropriate unit
|
||||
@@ -2026,7 +2060,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
// EXIT fields
|
||||
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);
|
||||
|
||||
// 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">
|
||||
Email Template *
|
||||
</Label>
|
||||
<Select value={templateId} onValueChange={setTemplateId} required>
|
||||
<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>
|
||||
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
|
||||
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
|
||||
</div>
|
||||
|
||||
@@ -2793,40 +2813,54 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
<div className="space-y-4 pl-3">
|
||||
<div>
|
||||
<Label htmlFor="editEventName">Event Name *</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<>
|
||||
<Select value={eventName} onValueChange={setEventName} required>
|
||||
<SelectTrigger id="editEventName" className="mt-1.5">
|
||||
<SelectValue placeholder="Select an event..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{eventNamesData.eventNames.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Select from previously tracked events in your project
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editEventName"
|
||||
type="text"
|
||||
value={eventName}
|
||||
onChange={e => setEventName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., email.clicked, user.upgraded"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
The workflow will pause until this event is triggered by the contact
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="editEventName"
|
||||
type="text"
|
||||
value={eventName}
|
||||
onChange={e => {
|
||||
setEventName(e.target.value);
|
||||
setEventPopoverOpen(true);
|
||||
}}
|
||||
onFocus={() => setEventPopoverOpen(true)}
|
||||
onBlur={() => {
|
||||
setTimeout(() => setEventPopoverOpen(false), 150);
|
||||
}}
|
||||
required
|
||||
placeholder="e.g., email.clicked, user.upgraded"
|
||||
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 “{eventName.trim()}”
|
||||
</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>
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -14,11 +18,6 @@ import {
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Workflow} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
@@ -321,6 +320,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
|
||||
const [allowReentry, setAllowReentry] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -391,34 +391,65 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="eventName">Trigger Event *</Label>
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Label htmlFor="createEventName">Trigger Event *</Label>
|
||||
{/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */}
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="eventName"
|
||||
id="createEventName"
|
||||
type="text"
|
||||
value={eventName}
|
||||
onChange={e => setEventName(e.target.value)}
|
||||
required
|
||||
onChange={e => {
|
||||
setEventName(e.target.value);
|
||||
setEventPopoverOpen(true);
|
||||
}}
|
||||
onFocus={() => setEventPopoverOpen(true)}
|
||||
onBlur={() => {
|
||||
// 延遲關閉,讓 CommandItem 的 onSelect 有時間觸發
|
||||
setTimeout(() => setEventPopoverOpen(false), 150);
|
||||
}}
|
||||
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 “{eventName.trim()}”
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0
|
||||
? 'Select from previously tracked events'
|
||||
: 'No events tracked yet. Enter the event name that will trigger this workflow.'}
|
||||
The event that triggers this workflow to start for a contact
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
@@ -291,6 +291,9 @@ model Campaign {
|
||||
fromName String?
|
||||
replyTo String?
|
||||
|
||||
// Campaign type
|
||||
type TemplateType @default(MARKETING)
|
||||
|
||||
// Audience selection
|
||||
audienceType CampaignAudienceType @default(ALL)
|
||||
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
|
||||
@@ -644,6 +647,7 @@ enum Role {
|
||||
enum TemplateType {
|
||||
TRANSACTIONAL
|
||||
MARKETING
|
||||
HEADLESS
|
||||
}
|
||||
|
||||
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,4 +1,5 @@
|
||||
export {ProjectDisabledEmail} from './ProjectDisabled';
|
||||
export {ProjectDisabledPaymentEmail} from './ProjectDisabledPayment';
|
||||
export {BillingLimitWarningEmail} from './BillingLimitWarning';
|
||||
export {BillingLimitExceededEmail} from './BillingLimitExceeded';
|
||||
export {EmailVerificationEmail} from './EmailVerification';
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './schemas/index.js';
|
||||
export * from './operators.js';
|
||||
export * from './template.js';
|
||||
export * from './i18n/index.js';
|
||||
export * from './unsubscribe.js';
|
||||
|
||||
@@ -70,8 +70,7 @@ export const ProjectSchemas = {
|
||||
tracking: z.nativeEnum(TrackingMode).optional(),
|
||||
language: z
|
||||
.string()
|
||||
.length(2)
|
||||
.regex(/^[a-z]{2}$/)
|
||||
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
||||
.optional(),
|
||||
}),
|
||||
} as const;
|
||||
@@ -349,6 +348,7 @@ export const CampaignSchemas = {
|
||||
from: email,
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: email.nullish(),
|
||||
type: z.nativeEnum(TemplateType).default(TemplateType.MARKETING),
|
||||
audienceType: z.nativeEnum(CampaignAudienceType),
|
||||
audienceCondition: filterConditionSchema.optional(),
|
||||
segmentId: uuid.optional(),
|
||||
@@ -364,6 +364,7 @@ export const CampaignSchemas = {
|
||||
from: z.string().optional(),
|
||||
fromName: z.string().max(100).nullish(),
|
||||
replyTo: z.string().nullish(),
|
||||
type: z.nativeEnum(TemplateType).optional(),
|
||||
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
|
||||
audienceCondition: filterConditionSchema.optional(),
|
||||
segmentId: z.string().optional(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Campaign service types
|
||||
*/
|
||||
|
||||
import type {CampaignAudienceType} from '@plunk/db';
|
||||
import type {CampaignAudienceType, TemplateType} from '@plunk/db';
|
||||
import type {FilterCondition} from '../segments/index.js';
|
||||
|
||||
/**
|
||||
@@ -16,6 +16,7 @@ export interface CreateCampaignData {
|
||||
from: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: TemplateType;
|
||||
audienceType: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
@@ -32,6 +33,7 @@ export interface UpdateCampaignData {
|
||||
from?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: TemplateType;
|
||||
audienceType?: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface CampaignFactoryOptions {
|
||||
status?: CampaignStatus;
|
||||
scheduledFor?: Date | null;
|
||||
segmentId?: string | null;
|
||||
type?: TemplateType;
|
||||
}
|
||||
|
||||
export interface WorkflowFactoryOptions {
|
||||
@@ -224,6 +225,7 @@ export class TestFactories {
|
||||
status: options.status || CampaignStatus.DRAFT,
|
||||
scheduledFor: options.scheduledFor,
|
||||
segmentId: options.segmentId,
|
||||
type: options.type || TemplateType.MARKETING,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user