Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbde3cce3f | ||
|
|
df18979e5e | ||
|
|
a981a8bba4 | ||
|
|
7834b9e7ef | ||
|
|
9e2400c6de | ||
|
|
fb5aa8796a | ||
|
|
284838279d | ||
|
|
2c5a71518d | ||
|
|
a8014cf7cf | ||
|
|
3214f6c42d | ||
|
|
d24259e8d2 | ||
|
|
3343e891bd | ||
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3e3117e4a7 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 |
@@ -29,7 +29,7 @@ WIKI_URI=http://localhost:1000
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.7.1"
|
||||
".": "0.8.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||
|
||||
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
@@ -135,7 +135,7 @@ between groups.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ ARG TARGETPLATFORM
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://next.useplunk.com
|
||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -53,8 +57,8 @@ export class Webhooks {
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com.
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
@@ -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
|
||||
@@ -652,7 +658,7 @@ export class EmailService {
|
||||
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in EmailPreviewModal.tsx.
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
@@ -692,7 +698,7 @@ export class EmailService {
|
||||
|
||||
/**
|
||||
* Wraps visual editor content with a full HTML document and prose styles.
|
||||
* Mirrors wrapEmailWithStyles() in EmailPreviewModal.tsx so sent emails
|
||||
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
||||
* match the preview modal exactly.
|
||||
*/
|
||||
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||
@@ -1093,7 +1099,7 @@ export class EmailService {
|
||||
<tr>
|
||||
<td style="width:180px;">
|
||||
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
||||
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
<img alt="Powered by Plunk" height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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';
|
||||
@@ -531,12 +533,9 @@ export class WorkflowExecutionService {
|
||||
throw new Error('No template configured for SEND_EMAIL step');
|
||||
}
|
||||
|
||||
// Parse step config to get recipient configuration
|
||||
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||
const recipientConfig =
|
||||
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||
: {type: 'CONTACT'};
|
||||
// Parse and validate step config using schema
|
||||
const stepConfig = WorkflowStepConfigSchemas.sendEmail.parse(config);
|
||||
const recipientConfig = stepConfig.recipient || {type: 'CONTACT' as const};
|
||||
|
||||
// Get contact data for variable substitution
|
||||
const contact = execution.contact;
|
||||
@@ -566,19 +565,13 @@ export class WorkflowExecutionService {
|
||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||
|
||||
// Determine recipient email
|
||||
let recipientEmail = contact.email;
|
||||
let recipientContactId = contact.id;
|
||||
|
||||
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||
recipientEmail = recipientConfig.customEmail;
|
||||
// For custom recipients, we don't associate with a contact
|
||||
recipientContactId = contact.id; // Keep original contact for tracking
|
||||
}
|
||||
// Schema validation ensures customEmail exists when type is CUSTOM
|
||||
const recipientEmail = recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail! : contact.email;
|
||||
|
||||
// Send email via EmailService
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId: execution.workflow.projectId,
|
||||
contactId: recipientContactId,
|
||||
contactId: contact.id, // Keep original contact for tracking
|
||||
workflowExecutionId: execution.id,
|
||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||
templateId: step.template.id,
|
||||
@@ -588,7 +581,7 @@ export class WorkflowExecutionService {
|
||||
fromName: step.template.fromName || undefined,
|
||||
replyTo: step.template.replyTo || undefined,
|
||||
// Pass custom recipient email if specified
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -727,7 +720,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
|
||||
// Get the value to evaluate
|
||||
const contact = execution.contact;
|
||||
@@ -743,7 +736,7 @@ export class WorkflowExecutionService {
|
||||
// - data.firstName, data.lastName, etc.
|
||||
// - workflow.* (execution context - alias for event data)
|
||||
// - event.* (event data that triggered the workflow)
|
||||
const actualValue = this.resolveField(field, {
|
||||
const fieldData = {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -751,16 +744,47 @@ export class WorkflowExecutionService {
|
||||
data: contactData,
|
||||
workflow: context,
|
||||
event: context, // Alias for easier access to event data
|
||||
});
|
||||
};
|
||||
|
||||
// Evaluate the condition
|
||||
// Multi-branch mode (switch/case)
|
||||
if ('mode' in parsed && parsed.mode === 'multi') {
|
||||
const actualValue = this.resolveField(parsed.field, fieldData);
|
||||
|
||||
for (const branch of parsed.branches) {
|
||||
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: branch.name,
|
||||
actualValue,
|
||||
branch: branch.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No branch matched — use default
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: 'default',
|
||||
actualValue,
|
||||
branch: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy binary mode (if/else)
|
||||
const field = parsed.field;
|
||||
const operator = 'operator' in parsed ? parsed.operator : 'equals';
|
||||
const value = 'value' in parsed ? parsed.value : undefined;
|
||||
const actualValue = this.resolveField(field, fieldData);
|
||||
const result = this.evaluateCondition(actualValue, operator, value);
|
||||
|
||||
return {
|
||||
field,
|
||||
operator,
|
||||
expectedValue: value,
|
||||
actualValue,
|
||||
// Convert undefined to null so it's preserved in JSON (JSON.stringify removes undefined)
|
||||
actualValue: actualValue === undefined ? null : actualValue,
|
||||
result,
|
||||
branch: result ? 'yes' : 'no',
|
||||
};
|
||||
@@ -800,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
|
||||
*/
|
||||
@@ -837,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',
|
||||
@@ -996,7 +1103,15 @@ export class WorkflowExecutionService {
|
||||
* Helper: Resolve field value from object using dot notation
|
||||
*/
|
||||
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
||||
const parts = field.split('.');
|
||||
// Handle legacy "contact.data.X" format by converting to "data.X"
|
||||
// The UI historically showed examples like "contact.data.plan" but the field structure
|
||||
// has "data" as a top-level key, not nested under "contact"
|
||||
let normalizedField = field;
|
||||
if (field.startsWith('contact.data.')) {
|
||||
normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X"
|
||||
}
|
||||
|
||||
const parts = normalizedField.split('.');
|
||||
let value: unknown = data;
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
|
||||
expect(branch).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTI-BRANCH CONDITIONS (switch/case)
|
||||
// ========================================
|
||||
describe('Multi-Branch Conditions', () => {
|
||||
/**
|
||||
* Helper to create a workflow with a multi-branch condition step
|
||||
* Creates one exit step per branch + one for default
|
||||
*/
|
||||
async function createMultiBranchWorkflow(
|
||||
contactData: Record<string, unknown>,
|
||||
conditionConfig: Record<string, unknown>,
|
||||
) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: contactData,
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
const conditionStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Multi Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: conditionConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Create exit steps for each branch + default
|
||||
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
|
||||
const branchExits: Record<string, string> = {};
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const branch = branches[i]!;
|
||||
const exitStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: `${branch.name} Path`,
|
||||
position: {x: 200, y: i * 100},
|
||||
config: {reason: branch.name},
|
||||
},
|
||||
});
|
||||
branchExits[branch.id] = exitStep.id;
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: branch.id},
|
||||
priority: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create default exit
|
||||
const defaultExit = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Default Path',
|
||||
position: {x: 200, y: branches.length * 100},
|
||||
config: {reason: 'default'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: defaultExit.id,
|
||||
condition: {branch: 'default'},
|
||||
priority: branches.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Connect trigger to condition
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
|
||||
});
|
||||
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
|
||||
}
|
||||
|
||||
const planBranches = {
|
||||
mode: 'multi' as const,
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
|
||||
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
|
||||
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
|
||||
],
|
||||
};
|
||||
|
||||
it('should match the first matching branch', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-premium');
|
||||
});
|
||||
|
||||
it('should match a non-first branch correctly', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'enterprise'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-enterprise');
|
||||
});
|
||||
|
||||
it('should fall through to default when no branch matches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'starter'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should fall through to default when field is missing', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{other: 'value'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should support mixed operators across branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{score: 85},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.score',
|
||||
branches: [
|
||||
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
|
||||
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
|
||||
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-medium');
|
||||
});
|
||||
|
||||
it('should respect branch evaluation order (first match wins)', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
|
||||
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// Both match, but first one should win
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-first');
|
||||
});
|
||||
|
||||
it('should support exists/notExists operators in branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-has-plan');
|
||||
});
|
||||
|
||||
it('should route to correct exit step via transitions', async () => {
|
||||
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
|
||||
{plan: 'free'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
|
||||
// Check the execution completed and ended at the correct exit step
|
||||
const completedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
|
||||
// Workflow completed after reaching exit step
|
||||
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
|
||||
|
||||
// Verify it went through the "free" branch exit, not default
|
||||
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: branchExits['br-free'],
|
||||
},
|
||||
});
|
||||
expect(exitStepExecution).not.toBeNull();
|
||||
|
||||
// Verify it did NOT go through the default exit
|
||||
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: defaultExitId,
|
||||
},
|
||||
});
|
||||
expect(defaultStepExecution).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
||||
|
||||
// Generate breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||
{position: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="15 min"
|
||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="11 min"
|
||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
|
||||
<NextSeo
|
||||
title="Email Marketing & Deliverability Guides | Plunk"
|
||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||
canonical="https://next.useplunk.com/guides"
|
||||
canonical="https://www.useplunk.com/guides"
|
||||
openGraph={{
|
||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://next.useplunk.com/guides',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dmarc"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
|
||||
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-spf"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -32,13 +32,13 @@ export default function ToolsIndex() {
|
||||
<NextSeo
|
||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
canonical="https://next.useplunk.com/tools"
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
url: 'https://next.useplunk.com/tools',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function MarkdownToEmail() {
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
canonical="https://next.useplunk.com/tools/markdown-to-email"
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
url: 'https://next.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ export default function VerifyEmailPage() {
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
canonical="https://next.useplunk.com/tools/verify-email"
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
url: 'https://next.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ActiveCampaignComparison() {
|
||||
<NextSeo
|
||||
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
||||
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
||||
canonical="https://next.useplunk.com/vs/activecampaign"
|
||||
canonical="https://www.useplunk.com/vs/activecampaign"
|
||||
openGraph={{
|
||||
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://next.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function BentoComparison() {
|
||||
<NextSeo
|
||||
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
||||
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
||||
canonical="https://next.useplunk.com/vs/bento"
|
||||
canonical="https://www.useplunk.com/vs/bento"
|
||||
openGraph={{
|
||||
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://next.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function BrevoComparison() {
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
canonical="https://next.useplunk.com/vs/brevo"
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
url: 'https://next.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function ConvertkitComparison() {
|
||||
<NextSeo
|
||||
title="ConvertKit Alternative for Developers | Plunk"
|
||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation. No landing page bloat."
|
||||
canonical="https://next.useplunk.com/vs/convertkit"
|
||||
canonical="https://www.useplunk.com/vs/convertkit"
|
||||
openGraph={{
|
||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://next.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function CustomerioComparison() {
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
canonical="https://next.useplunk.com/vs/customerio"
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
url: 'https://next.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -96,13 +96,13 @@ export default function CompetitorsIndex() {
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
canonical="https://next.useplunk.com/vs"
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
url: 'https://next.useplunk.com/vs',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function KlaviyoComparison() {
|
||||
<NextSeo
|
||||
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
||||
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
||||
canonical="https://next.useplunk.com/vs/klaviyo"
|
||||
canonical="https://www.useplunk.com/vs/klaviyo"
|
||||
openGraph={{
|
||||
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://next.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function LoopsComparison() {
|
||||
<NextSeo
|
||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/loops"
|
||||
canonical="https://www.useplunk.com/vs/loops"
|
||||
openGraph={{
|
||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||
url: 'https://next.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
url: 'https://www.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function MailchimpComparison() {
|
||||
<NextSeo
|
||||
title="Mailchimp Alternative for Developers | Plunk"
|
||||
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
|
||||
canonical="https://next.useplunk.com/vs/mailchimp"
|
||||
canonical="https://www.useplunk.com/vs/mailchimp"
|
||||
openGraph={{
|
||||
title: 'Mailchimp Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||
url: 'https://next.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function MailerliteComparison() {
|
||||
<NextSeo
|
||||
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
|
||||
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/mailerlite"
|
||||
canonical="https://www.useplunk.com/vs/mailerlite"
|
||||
openGraph={{
|
||||
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||
url: 'https://next.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function MailgunComparison() {
|
||||
<NextSeo
|
||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||
canonical="https://next.useplunk.com/vs/mailgun"
|
||||
canonical="https://www.useplunk.com/vs/mailgun"
|
||||
openGraph={{
|
||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
url: 'https://www.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -54,13 +54,13 @@ export default function PostmarkComparison() {
|
||||
<NextSeo
|
||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||
canonical="https://next.useplunk.com/vs/postmark"
|
||||
canonical="https://www.useplunk.com/vs/postmark"
|
||||
openGraph={{
|
||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||
description:
|
||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||
url: 'https://next.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
url: 'https://www.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function ResendComparison() {
|
||||
<NextSeo
|
||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||
canonical="https://next.useplunk.com/vs/resend"
|
||||
canonical="https://www.useplunk.com/vs/resend"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||
description:
|
||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
url: 'https://www.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function SendGridComparison() {
|
||||
<NextSeo
|
||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||
canonical="https://next.useplunk.com/vs/sendgrid"
|
||||
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||
openGraph={{
|
||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||
url: 'https://next.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||
import {network} from '../../lib/network';
|
||||
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
interface EmailEditorProps {
|
||||
@@ -51,54 +52,6 @@ const commonVariables = [
|
||||
{name: 'manageUrl', description: 'Manage link'},
|
||||
];
|
||||
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
|
||||
// Detect if initial value has custom HTML and start in appropriate mode
|
||||
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
|
||||
@@ -348,31 +301,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
|
||||
if (iframeDoc) {
|
||||
const previewContent = getPreviewHtml();
|
||||
const fullHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const fullHtml = wrapEmailWithStyles(previewContent);
|
||||
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(fullHtml);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
|
||||
import {Monitor, Smartphone, Tablet} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import {wrapEmailWithStyles} from '../lib/emailStyles';
|
||||
|
||||
interface EmailPreviewModalProps {
|
||||
open: boolean;
|
||||
@@ -16,391 +17,6 @@ interface EmailPreviewModalProps {
|
||||
|
||||
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to wrap email body with necessary styles for proper rendering
|
||||
// Only wraps visual editor content - custom HTML is returned as-is
|
||||
const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
// If this is custom HTML (from HTML editor), return it as-is without wrapping
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
// For visual editor content, wrap with prose styles
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export function EmailPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,6 +91,34 @@ const STEP_TYPE_BG = {
|
||||
UPDATE_CONTACT: '#e0e7ff',
|
||||
};
|
||||
|
||||
// Multi-branch condition helpers
|
||||
function getExpectedBranches(config: any): string[] {
|
||||
if (config?.mode === 'multi') {
|
||||
return [...(config.branches || []).map((b: any) => b.id), 'default'];
|
||||
}
|
||||
return ['yes', 'no'];
|
||||
}
|
||||
|
||||
function getBranchLabel(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return 'Default';
|
||||
const branch = config.branches?.find((b: any) => b.id === branchId);
|
||||
return branch?.name || branchId;
|
||||
}
|
||||
return branchId === 'yes' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
const BRANCH_COLORS = ['#16a34a', '#dc2626', '#2563eb', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#059669'];
|
||||
|
||||
function getBranchColor(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return '#64748b';
|
||||
const idx = config.branches?.findIndex((b: any) => b.id === branchId) ?? 0;
|
||||
return BRANCH_COLORS[idx % BRANCH_COLORS.length]!;
|
||||
}
|
||||
return branchId === 'yes' ? '#16a34a' : '#dc2626';
|
||||
}
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
@@ -129,6 +157,7 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
});
|
||||
|
||||
// Center "Add Step" nodes directly below their parent nodes
|
||||
// For condition nodes with multiple branches, let dagre handle horizontal spread
|
||||
const adjustedNodes = layoutedNodes.map(node => {
|
||||
if (node.type === 'addStep') {
|
||||
// Find the parent node (the source of the edge connecting to this add node)
|
||||
@@ -136,7 +165,17 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
if (parentEdge) {
|
||||
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||
if (parentNode) {
|
||||
// Center the add node below the parent node
|
||||
// Check how many add-step siblings share this parent
|
||||
const siblingAddNodes = edges.filter(
|
||||
e => e.source === parentEdge.source && layoutedNodes.find(n => n.id === e.target && n.type === 'addStep'),
|
||||
);
|
||||
|
||||
if (siblingAddNodes.length > 1) {
|
||||
// Multiple branches — let dagre's spread positioning stand, only keep y
|
||||
return node;
|
||||
}
|
||||
|
||||
// Single add node — center below parent
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
@@ -324,9 +363,16 @@ function CustomNode({
|
||||
: String(data.config.field)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
{data.config.mode === 'multi' ? (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} +
|
||||
default
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -393,7 +439,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [addStepContext, setAddStepContext] = useState<{
|
||||
fromStepId: string | null;
|
||||
branch?: 'yes' | 'no';
|
||||
branch?: string;
|
||||
} | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||
@@ -455,41 +501,27 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
// Check for yes and no branches
|
||||
const hasYesBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
});
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-yes`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'Yes',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||
},
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-no`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'No',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-${branchId}`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: getBranchLabel(step.config, branchId),
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: branchId}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For non-condition steps, add + node if no outgoing transitions
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
@@ -520,7 +552,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const condition = transition.condition;
|
||||
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
||||
const branch =
|
||||
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
|
||||
condition && typeof condition === 'object' && 'branch' in condition
|
||||
? (condition.branch as string)
|
||||
: undefined;
|
||||
|
||||
const branchColor = branch ? getBranchColor(step.config, branch) : '#94a3b8';
|
||||
const branchLabel = branch ? getBranchLabel(step.config, branch) : undefined;
|
||||
|
||||
edges.push({
|
||||
id: transition.id,
|
||||
@@ -528,9 +565,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
target: transition.toStepId,
|
||||
type: 'smoothstep',
|
||||
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
||||
label: isConditional ? (branch === 'yes' ? 'Yes' : 'No') : undefined,
|
||||
label: isConditional ? branchLabel : undefined,
|
||||
labelStyle: {
|
||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
||||
fill: isConditional ? branchColor : '#64748b',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
@@ -541,12 +578,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
stroke: isConditional ? branchColor : '#94a3b8',
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
color: isConditional ? branchColor : '#94a3b8',
|
||||
width: 22,
|
||||
height: 22,
|
||||
},
|
||||
@@ -558,47 +595,37 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return;
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-yes-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-yes`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'Yes',
|
||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition &&
|
||||
typeof t.condition === 'object' &&
|
||||
'branch' in t.condition &&
|
||||
t.condition.branch === branchId,
|
||||
);
|
||||
|
||||
if (!hasNoBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-no-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-no`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'No',
|
||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
const color = getBranchColor(step.config, branchId);
|
||||
const label = getBranchLabel(step.config, branchId);
|
||||
|
||||
edges.push({
|
||||
id: `${step.id}-add-${branchId}-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-${branchId}`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label,
|
||||
labelStyle: {fill: color, fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
edges.push({
|
||||
@@ -645,15 +672,13 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const connectedAddNodes = updatedNodes.filter(node => {
|
||||
if (node.type !== 'addStep') return false;
|
||||
// Check if this add node is connected to the moved node
|
||||
return (
|
||||
node.id === `${movedNode.id}-add` ||
|
||||
node.id === `${movedNode.id}-add-yes` ||
|
||||
node.id === `${movedNode.id}-add-no`
|
||||
);
|
||||
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
|
||||
});
|
||||
|
||||
// Update positions of connected "+" nodes to stay centered below parent
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
// Update positions of connected "+" nodes to stay below parent
|
||||
if (connectedAddNodes.length === 1) {
|
||||
// Single add node — center below parent
|
||||
const addNode = connectedAddNodes[0]!;
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
@@ -665,7 +690,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (connectedAddNodes.length > 1) {
|
||||
// Multiple branches — move all add nodes by the same delta as the parent
|
||||
const oldX = movedNode.position.x;
|
||||
const deltaX = change.position.x - oldX;
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
updatedNodes[addNodeIndex] = {
|
||||
...existingNode,
|
||||
position: {
|
||||
x: existingNode.position.x + deltaX,
|
||||
y: existingNode.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -733,7 +775,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
|
||||
// Create the transition with proper condition
|
||||
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
||||
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
|
||||
const expectedBranches = fromStep.type === 'CONDITION' ? getExpectedBranches(fromStep.config) : [];
|
||||
const branchIndex = addStepContext.branch ? expectedBranches.indexOf(addStepContext.branch) : -1;
|
||||
const priority = branchIndex >= 0 ? branchIndex : 0;
|
||||
|
||||
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
||||
'POST',
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -205,23 +210,33 @@ export default function WorkflowEditorPage() {
|
||||
break;
|
||||
|
||||
case 'CONDITION':
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
if (config.mode === 'multi') {
|
||||
// Multi-branch validation
|
||||
if (!config.field) {
|
||||
errors.push(`"${step.name}" step is missing condition field`);
|
||||
}
|
||||
if (!Array.isArray(config.branches) || config.branches.length === 0) {
|
||||
errors.push(`"${step.name}" step needs at least one branch`);
|
||||
}
|
||||
} else {
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -264,20 +279,37 @@ export default function WorkflowEditorPage() {
|
||||
errors.push('Workflow must have a trigger step');
|
||||
}
|
||||
|
||||
// Check for CONDITION steps that don't have both yes and no branches
|
||||
// Check for CONDITION steps that don't have all required branches connected
|
||||
workflow.steps.forEach(step => {
|
||||
if (step.type === 'CONDITION' && step.outgoingTransitions) {
|
||||
const hasYesBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
|
||||
|
||||
// Determine expected branches based on mode
|
||||
let expectedBranches: string[];
|
||||
if ((config as any).mode === 'multi' && Array.isArray((config as any).branches)) {
|
||||
expectedBranches = [...(config as any).branches.map((b: any) => b.id), 'default'];
|
||||
} else {
|
||||
expectedBranches = ['yes', 'no'];
|
||||
}
|
||||
|
||||
const missingBranches = expectedBranches.filter(branchId => {
|
||||
return !step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasYesBranch || !hasNoBranch) {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
if (missingBranches.length > 0) {
|
||||
if ((config as any).mode === 'multi') {
|
||||
const branchNames = missingBranches.map(id => {
|
||||
if (id === 'default') return 'Default';
|
||||
const branch = (config as any).branches?.find((b: any) => b.id === id);
|
||||
return branch?.name || id;
|
||||
});
|
||||
errors.push(`"${step.name}" condition step is missing connections for: ${branchNames.join(', ')}`);
|
||||
} else {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -766,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
|
||||
@@ -825,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>
|
||||
@@ -934,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');
|
||||
|
||||
@@ -950,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
|
||||
@@ -1286,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>
|
||||
|
||||
@@ -1584,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>
|
||||
|
||||
@@ -1839,6 +1899,59 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
);
|
||||
|
||||
// CONDITION fields - handle both old format (object) and new format (string)
|
||||
const [conditionMode, setConditionMode] = useState<'binary' | 'multi'>(() => {
|
||||
return config?.mode === 'multi' ? 'multi' : 'binary';
|
||||
});
|
||||
|
||||
// Helper to check if switching from multi to binary is safe
|
||||
const hasMultiBranchConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode !== 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has a branch that's not 'yes' or 'no' (multi-branch specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
// Multi-branch specific branches (not the simple if/else branches)
|
||||
return branch !== 'yes' && branch !== 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to check if switching from binary to multi is safe
|
||||
const hasBinaryConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode === 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has 'yes' or 'no' branches (binary-specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
return branch === 'yes' || branch === 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Safe mode change handler
|
||||
const handleModeChange = (newMode: 'binary' | 'multi') => {
|
||||
// If switching from multi to binary, check for multi-branch connections
|
||||
if (conditionMode === 'multi' && newMode === 'binary' && hasMultiBranchConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
// If switching from binary to multi, check for binary connections
|
||||
if (conditionMode === 'binary' && newMode === 'multi' && hasBinaryConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
setConditionMode(newMode);
|
||||
};
|
||||
const [conditionField, setConditionField] = useState(() => {
|
||||
if (!config?.field) return '';
|
||||
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
|
||||
@@ -1850,6 +1963,19 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
});
|
||||
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
|
||||
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
|
||||
const [conditionBranches, setConditionBranches] = useState<
|
||||
Array<{id: string; name: string; operator: string; value: string}>
|
||||
>(() => {
|
||||
if (config?.mode === 'multi' && Array.isArray(config.branches)) {
|
||||
return config.branches.map((b: any) => ({
|
||||
id: String(b.id || crypto.randomUUID().slice(0, 8)),
|
||||
name: String(b.name || ''),
|
||||
operator: String(b.operator || 'equals'),
|
||||
value: String(b.value ?? ''),
|
||||
}));
|
||||
}
|
||||
return [{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}];
|
||||
});
|
||||
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(false);
|
||||
|
||||
@@ -1898,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
|
||||
@@ -1933,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
|
||||
@@ -2020,17 +2147,50 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
}
|
||||
newConfig = {amount, unit: delayUnit};
|
||||
} else if (step.type === 'CONDITION') {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
if (conditionMode === 'multi') {
|
||||
// Validate branches
|
||||
const validBranches = conditionBranches.filter(b => b.name.trim());
|
||||
if (validBranches.length === 0) {
|
||||
toast.error('At least one branch with a name is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!conditionField) {
|
||||
toast.error('Please select a field');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
newConfig = {
|
||||
mode: 'multi' as const,
|
||||
field: conditionField,
|
||||
branches: validBranches.map(b => {
|
||||
let parsedValue: string | number | boolean = b.value;
|
||||
if (b.value === 'true') parsedValue = true;
|
||||
else if (b.value === 'false') parsedValue = false;
|
||||
else if (b.value !== '' && !isNaN(Number(b.value))) parsedValue = Number(b.value);
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name.trim(),
|
||||
operator: b.operator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}
|
||||
} else if (step.type === 'EXIT') {
|
||||
newConfig = {reason: exitReason};
|
||||
} else if (step.type === 'WEBHOOK') {
|
||||
@@ -2186,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>
|
||||
|
||||
@@ -2318,10 +2464,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pl-3">
|
||||
<p className="text-xs text-neutral-600">
|
||||
Define the condition that will be evaluated to determine which path the workflow should follow
|
||||
</p>
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('binary')}
|
||||
disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'binary'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'multi' && hasMultiBranchConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Simple (If/Else)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('multi')}
|
||||
disabled={conditionMode === 'binary' && hasBinaryConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'multi'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'binary' && hasBinaryConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Multi-branch (Switch)
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
{conditionMode === 'binary'
|
||||
? 'Evaluates a single condition with Yes/No paths'
|
||||
: 'Match a field against multiple values, each routing to its own branch'}
|
||||
</p>
|
||||
{hasMultiBranchConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections
|
||||
first.
|
||||
</div>
|
||||
)}
|
||||
{hasBinaryConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch
|
||||
connections first.
|
||||
</div>
|
||||
)}
|
||||
{conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') &&
|
||||
!hasMultiBranchConnections() &&
|
||||
!hasBinaryConnections() &&
|
||||
(step as any).outgoingTransitions &&
|
||||
(step as any).outgoingTransitions.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Changing mode will disconnect existing branches. You will need to rewire them.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field selector (shared between modes) */}
|
||||
<div>
|
||||
<Label htmlFor="editConditionField">Field to Check *</Label>
|
||||
{loadingFields ? (
|
||||
@@ -2396,80 +2603,199 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
{/* Binary mode: single operator + value */}
|
||||
{conditionMode === 'binary' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Multi-branch mode: dynamic list of branches */}
|
||||
{conditionMode === 'multi' && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Branches</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Each branch defines a condition. The first matching branch is taken. Contacts not matching any
|
||||
branch follow the Default path.
|
||||
</p>
|
||||
|
||||
{conditionBranches.map((branch, idx) => (
|
||||
<div
|
||||
key={branch.id}
|
||||
className="p-3 border border-neutral-200 rounded-lg space-y-3 bg-neutral-50/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">Branch {idx + 1}</span>
|
||||
{conditionBranches.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConditionBranches(prev => prev.filter(b => b.id !== branch.id))}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Name *</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.name}
|
||||
onChange={e => {
|
||||
const newName = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, name: newName} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Premium, Free, Enterprise"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={branch.operator}
|
||||
onValueChange={val =>
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, operator: val} : b)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!['exists', 'notExists'].includes(branch.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.value}
|
||||
onChange={e => {
|
||||
const newValue = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, value: newValue} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="Value..."
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{conditionBranches.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setConditionBranches(prev => [
|
||||
...prev,
|
||||
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
|
||||
])
|
||||
}
|
||||
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="p-2 bg-neutral-100 rounded-lg text-xs text-neutral-600 flex items-start gap-2">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Branches are evaluated in order. The first match wins. Contacts not matching any branch will
|
||||
follow the <strong>Default</strong> path.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2487,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>
|
||||
|
||||
|
||||
@@ -26,23 +26,23 @@ export default function Layout({children}: {children: ReactNode}) {
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="og:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="og:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="og:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="og:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="twitter:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="twitter:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="twitter:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="twitter:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -337,6 +337,6 @@ curl -X POST {{API_URL}}/v1/send \
|
||||
|
||||
## What's next
|
||||
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
- [View detailed endpoint docs](/api-reference/public-api)
|
||||
- [Check error codes](/api-reference/errors)
|
||||
- [Send a transactional email](/api-reference/public-api/sendEmail)
|
||||
- [Track an event](/api-reference/public-api/trackEvent)
|
||||
- [Error codes reference](/api-reference/errors)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -3,64 +3,118 @@ title: Environment Variables
|
||||
description: Configuration reference
|
||||
---
|
||||
|
||||
## Required
|
||||
## Security & Database
|
||||
|
||||
```bash
|
||||
# Security
|
||||
JWT_SECRET="your-secret" # openssl rand -base64 32
|
||||
DB_PASSWORD="your-password"
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `JWT_SECRET` | Yes | Secret key used to sign JWT tokens. Generate with `openssl rand -base64 32`. | `s3cr3t...` |
|
||||
| `DB_PASSWORD` | Yes | PostgreSQL database password. Used by the Docker Compose setup. | `changeme123` |
|
||||
| `DATABASE_URL` | Yes | Full PostgreSQL connection string. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
|
||||
| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
|
||||
| `PORT` | No | Port the API server listens on. | `8080` (default) |
|
||||
|
||||
# Database (auto-configured in Docker)
|
||||
DATABASE_URL="postgresql://plunk:password@postgres:5432/plunk"
|
||||
REDIS_URL="redis://redis:6379"
|
||||
## URLs & Domains
|
||||
|
||||
# AWS SES
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-key"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret"
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
```
|
||||
Set your subdomains here. The application automatically derives all internal and client-side URLs from these at container startup — you don't need to set `*_URI` or `NEXT_PUBLIC_*` variables manually.
|
||||
|
||||
## Domains
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `API_DOMAIN` | Yes | Subdomain for the API server. | `api.yourdomain.com` |
|
||||
| `DASHBOARD_DOMAIN` | Yes | Subdomain for the dashboard app. | `app.yourdomain.com` |
|
||||
| `LANDING_DOMAIN` | Yes | Subdomain for the landing page. | `www.yourdomain.com` |
|
||||
| `WIKI_DOMAIN` | Yes | Subdomain for the documentation site. | `docs.yourdomain.com` |
|
||||
| `USE_HTTPS` | No | Set to `true` when running behind a TLS-terminating reverse proxy. Used to construct URLs with the correct protocol. | `false` (default) |
|
||||
|
||||
```bash
|
||||
# Subdomains for routing
|
||||
API_DOMAIN="api.yourdomain.com"
|
||||
DASHBOARD_DOMAIN="app.yourdomain.com"
|
||||
LANDING_DOMAIN="www.yourdomain.com"
|
||||
WIKI_DOMAIN="docs.yourdomain.com"
|
||||
SMTP_DOMAIN="smtp.yourdomain.com"
|
||||
## AWS SES
|
||||
|
||||
# Protocol
|
||||
USE_HTTPS="true" # false for local dev
|
||||
```
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `AWS_SES_REGION` | Yes | AWS region where SES is configured. | `us-east-1` |
|
||||
| `AWS_SES_ACCESS_KEY_ID` | Yes | AWS access key ID with SES send permissions. | `AKIA...` |
|
||||
| `AWS_SES_SECRET_ACCESS_KEY` | Yes | AWS secret access key for SES. | `wJalr...` |
|
||||
| `SES_CONFIGURATION_SET` | No | SES configuration set name used for open/click tracking. | `plunk-configuration-set` (default) |
|
||||
| `SES_CONFIGURATION_SET_NO_TRACKING` | No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | `plunk-no-tracking-configuration-set` (default) |
|
||||
|
||||
## Storage (Minio)
|
||||
|
||||
Defaults work with bundled Minio. Only set for external S3:
|
||||
The bundled Docker setup includes Minio with defaults that work out of the box. Only change these when connecting to an external S3-compatible bucket.
|
||||
|
||||
```bash
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
S3_ACCESS_KEY_ID="plunk"
|
||||
S3_ACCESS_KEY_SECRET="plunkminiopass"
|
||||
S3_BUCKET="uploads"
|
||||
S3_PUBLIC_URL="http://localhost:9000/uploads"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
```
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `MINIO_ROOT_USER` | No | Minio root username (Docker Compose only). | `plunk` |
|
||||
| `MINIO_ROOT_PASSWORD` | No | Minio root password (Docker Compose only). | `plunkminiopass` |
|
||||
| `MINIO_API_PORT` | No | Port for the Minio API (Docker Compose only). | `9000` |
|
||||
| `MINIO_CONSOLE_PORT` | No | Port for the Minio console UI (Docker Compose only). | `9001` |
|
||||
| `S3_ENDPOINT` | No | S3 or Minio endpoint URL. | `http://minio:9000` |
|
||||
| `S3_ACCESS_KEY_ID` | No | S3 or Minio access key. | — |
|
||||
| `S3_ACCESS_KEY_SECRET` | No | S3 or Minio secret key. | — |
|
||||
| `S3_BUCKET` | No | Bucket name for file uploads. | `uploads` |
|
||||
| `S3_PUBLIC_URL` | No | Publicly accessible base URL for stored files. | — |
|
||||
| `S3_FORCE_PATH_STYLE` | No | Use path-style URLs instead of virtual-hosted. Required for Minio. | `true` |
|
||||
|
||||
## Optional
|
||||
## SMTP Server
|
||||
|
||||
```bash
|
||||
# OAuth
|
||||
GITHUB_OAUTH_CLIENT="client-id"
|
||||
GITHUB_OAUTH_SECRET="client-secret"
|
||||
GOOGLE_OAUTH_CLIENT="client-id"
|
||||
GOOGLE_OAUTH_SECRET="client-secret"
|
||||
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
|
||||
|
||||
# Stripe
|
||||
STRIPE_SK="sk_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `SMTP_DOMAIN` | No | SMTP relay domain. Required when using Traefik's `acme.json` with multiple certificates so the correct cert can be selected. | `localhost` |
|
||||
| `SMTP_ENABLED` | No | Explicitly enable SMTP features in the UI. Automatically enabled when `SMTP_DOMAIN` is set to a non-localhost value in production. | `false` |
|
||||
| `PORT_SECURE` | No | SMTPS port (implicit TLS). | `465` |
|
||||
| `PORT_SUBMISSION` | No | SMTP submission port (STARTTLS). | `587` |
|
||||
| `MAX_RECIPIENTS` | No | Maximum number of recipients per email. | `5` |
|
||||
|
||||
# Notifications
|
||||
NTFY_URL="http://ntfy/plunk-notifications"
|
||||
```
|
||||
## OAuth
|
||||
|
||||
Enables social login. Register an OAuth app with each provider and add the credentials here.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_OAUTH_CLIENT` | No | GitHub OAuth app client ID. |
|
||||
| `GITHUB_OAUTH_SECRET` | No | GitHub OAuth app client secret. |
|
||||
| `GOOGLE_OAUTH_CLIENT` | No | Google OAuth app client ID. |
|
||||
| `GOOGLE_OAUTH_SECRET` | No | Google OAuth app client secret. |
|
||||
|
||||
## Stripe
|
||||
|
||||
Required if you want to enable billing features. All Stripe variables must be set together for billing to activate.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `STRIPE_SK` | No | Stripe secret key. |
|
||||
| `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook signing secret for verifying events. |
|
||||
| `STRIPE_PRICE_ONBOARDING` | No | Stripe price ID for the one-time onboarding fee. |
|
||||
| `STRIPE_PRICE_EMAIL_USAGE` | No | Stripe price ID for metered pay-per-email usage. |
|
||||
| `STRIPE_METER_EVENT_NAME` | No | Stripe meter event name. | `emails` (default) |
|
||||
|
||||
## Platform Emails
|
||||
|
||||
When configured, Plunk will send email notifications to users for critical events (e.g. project disabled, billing limits reached). Without these, only ntfy notifications are sent.
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `PLUNK_API_KEY` | No | API key for a Plunk instance to send transactional emails. | `pk_...` |
|
||||
| `PLUNK_FROM_ADDRESS` | No | From address used for platform notification emails. | `[email protected]` |
|
||||
|
||||
## Notifications (ntfy)
|
||||
|
||||
Plunk bundles a self-hosted [ntfy](https://ntfy.sh) server for internal system notifications.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `NTFY_PORT` | No | Port for the ntfy web UI (Docker Compose only). | `8080` |
|
||||
| `NTFY_URL` | No | ntfy topic URL. Change this to use an external ntfy.sh server or your own instance. | `http://ntfy/plunk-notifications` |
|
||||
|
||||
## User Management
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `DISABLE_SIGNUPS` | No | When `true`, the signup endpoint rejects new registrations. Useful for private instances. | `false` |
|
||||
| `VERIFY_EMAIL_ON_SIGNUP` | No | When `true`, validates emails on signup — checks for disposable domains, plus-addressing, domain existence, and MX records. | `false` |
|
||||
|
||||
## Security
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `AUTO_PROJECT_DISABLE` | No | When `true`, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to `false` to manage project status manually. | `true` |
|
||||
| `EMAIL_RATE_LIMIT_PER_SECOND` | No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — |
|
||||
|
||||
@@ -46,7 +46,7 @@ function filePathToUrl(filePath) {
|
||||
}
|
||||
|
||||
const config = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://next-wiki.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://docs.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
additionalPaths: async () => {
|
||||
const contentDocsPath = path.join(__dirname, 'content', 'docs');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "Plunk Support",
|
||||
"url": "https://next.useplunk.com"
|
||||
"url": "https://www.useplunk.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -19,8 +19,8 @@ echo "🔍 Generating URL manifest for $APP_NAME..."
|
||||
# Define placeholder URLs that will be replaced at runtime
|
||||
PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Output manifest file
|
||||
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
|
||||
|
||||
@@ -13,8 +13,8 @@ replace_urls_in_app() {
|
||||
# Define placeholder URLs (built into the app at compile time)
|
||||
local PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
local PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Use pre-generated manifest instead of scanning all files
|
||||
local manifest_file="$app_dir/.next/url-manifest.txt"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.7.1",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -6,7 +6,7 @@ interface FooterProps {
|
||||
landingUrl?: string;
|
||||
}
|
||||
|
||||
export function Footer({projectId, landingUrl = 'https://next.useplunk.com'}: FooterProps) {
|
||||
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
|
||||
return (
|
||||
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
|
||||
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Header() {
|
||||
return (
|
||||
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||
<Img
|
||||
src="https://next.useplunk.com/assets/logo.png"
|
||||
src="https://www.useplunk.com/assets/logo.png"
|
||||
alt="Plunk"
|
||||
width="40"
|
||||
height="40"
|
||||
|
||||
@@ -21,7 +21,7 @@ export function BillingLimitExceededEmail({
|
||||
limit = 10000,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitExceededEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function BillingLimitWarningEmail({
|
||||
percentage = 85,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitWarningEmailProps) {
|
||||
const percentageRounded = Math.round(percentage);
|
||||
const remaining = limit - usage;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainUnverifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainUnverifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainVerifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainVerifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface EmailVerificationEmailProps {
|
||||
export function EmailVerificationEmail({
|
||||
email = '[email protected]',
|
||||
verificationUrl = 'https://api.useplunk.com/auth/verify-email?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: EmailVerificationEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PasswordResetEmailProps {
|
||||
export function PasswordResetEmail({
|
||||
email = '[email protected]',
|
||||
resetUrl = 'https://next-app.useplunk.com/auth/reset-password?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: PasswordResetEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function ProjectDisabledEmail({
|
||||
projectId = 'proj_example123',
|
||||
violations = ['Bounce rate exceeded 10% threshold', 'Complaint rate exceeded 0.5% threshold'],
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: ProjectDisabledEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -10,6 +10,10 @@ import bgTranslations from './locales/bg.json' with {type: 'json'};
|
||||
import csTranslations from './locales/cs.json' with {type: 'json'};
|
||||
import plTranslations from './locales/pl.json' with {type: 'json'};
|
||||
import esTranslations from './locales/es.json' with {type: 'json'};
|
||||
import itTranslations from './locales/it.json' with {type: 'json'};
|
||||
import zhTWTranslations from './locales/zh-TW.json' with {type: 'json'};
|
||||
import zhHKTranslations from './locales/zh-HK.json' with {type: 'json'};
|
||||
import zhCNTranslations from './locales/zh-CN.json' with {type: 'json'};
|
||||
|
||||
export {
|
||||
SUPPORTED_LANGUAGES,
|
||||
@@ -45,6 +49,10 @@ const translationsMap: Record<string, Translations> = {
|
||||
cs: csTranslations,
|
||||
pl: plTranslations,
|
||||
es: esTranslations,
|
||||
it: itTranslations,
|
||||
'zh-TW': zhTWTranslations,
|
||||
'zh-HK': zhHKTranslations,
|
||||
'zh-CN': zhCNTranslations,
|
||||
};
|
||||
|
||||
// In-memory cache for loaded translations
|
||||
|
||||
@@ -16,6 +16,10 @@ export const SUPPORTED_LANGUAGES: Language[] = [
|
||||
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
|
||||
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
|
||||
{code: 'es', name: 'Spanish (Spain)', nativeName: 'Español (España)', flag: '🇪🇸'},
|
||||
{code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹'},
|
||||
{code: 'zh-TW', name: 'Chinese (Traditional, Taiwan)', nativeName: '繁體中文', flag: '🇹🇼'},
|
||||
{code: 'zh-HK', name: 'Chinese (Traditional, Hong Kong)', nativeName: '繁體中文(香港)', flag: '🇭🇰'},
|
||||
{code: 'zh-CN', name: 'Chinese (Simplified, China)', nativeName: '简体中文', flag: '🇨🇳'},
|
||||
];
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "Annulla l'iscrizione",
|
||||
"description": "Ci dispiace vederti andare via. Sei sicuro di voler annullare l'iscrizione per {email} dalla ricezione di e-mail?",
|
||||
"button": "Annulla iscrizione",
|
||||
"buttonLoading": "Annullamento in corso...",
|
||||
"managePreferences": "Gestisci invece le preferenze",
|
||||
"successTitle": "Iscrizione annullata",
|
||||
"successDescription": "L'iscrizione per {email} è stata annullata. Non riceverai più nostre e-mail.",
|
||||
"changedMind": "Hai cambiato idea?",
|
||||
"subscribeAgain": "Iscriviti di nuovo"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "Iscriviti agli aggiornamenti",
|
||||
"description": "Vuoi iscrivere {email} per ricevere e-mail?",
|
||||
"button": "Iscriviti",
|
||||
"buttonLoading": "Iscrizione in corso...",
|
||||
"successTitle": "Ti sei iscritto!",
|
||||
"successDescription": "{email} è ora iscritto per ricevere nostre e-mail."
|
||||
},
|
||||
"manage": {
|
||||
"title": "Gestisci le preferenze",
|
||||
"description": "Gestisci le preferenze e-mail per {email}",
|
||||
"subscriptionLabel": "Iscrizione e-mail",
|
||||
"subscribedStatus": "Al momento sei iscritto per ricevere e-mail",
|
||||
"unsubscribedStatus": "Al momento l'iscrizione alle e-mail è annullata",
|
||||
"subscribedSuccess": "Iscrizione avvenuta con successo!",
|
||||
"unsubscribedSuccess": "Iscrizione annullata con successo!",
|
||||
"unsubscribeCompletely": "Annulla completamente l'iscrizione",
|
||||
"subscribeToEmails": "Iscriviti alle e-mail",
|
||||
"disclaimer": "Questa pagina ti consente di gestire le tue preferenze e-mail. Lo stato della tua iscrizione viene aggiornato in tempo reale."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Caricamento in corso...",
|
||||
"error": "Errore"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "Hai ricevuto questa e-mail perché hai accettato di ricevere e-mail da {projectName}. Se non desideri più ricevere e-mail di questo tipo, ti preghiamo di",
|
||||
"updatePreferences": "aggiornare le tue preferenze"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消订阅",
|
||||
"description": "确定要取消 {email} 的邮件订阅吗?",
|
||||
"button": "取消订阅",
|
||||
"buttonLoading": "处理中...",
|
||||
"managePreferences": "改为管理偏好设置",
|
||||
"successTitle": "已取消订阅",
|
||||
"successDescription": "{email} 已取消订阅,你将不会再收到我们的邮件。",
|
||||
"changedMind": "改变主意了吗?",
|
||||
"subscribeAgain": "重新订阅"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "订阅最新消息",
|
||||
"description": "要为 {email} 订阅邮件吗?",
|
||||
"button": "订阅",
|
||||
"buttonLoading": "处理中...",
|
||||
"successTitle": "订阅成功!",
|
||||
"successDescription": "{email} 已订阅,你将会收到我们的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好设置",
|
||||
"description": "管理 {email} 的邮件偏好设置",
|
||||
"subscriptionLabel": "邮件订阅",
|
||||
"subscribedStatus": "目前已订阅,会收到邮件通知",
|
||||
"unsubscribedStatus": "目前已取消订阅",
|
||||
"subscribedSuccess": "订阅成功!",
|
||||
"unsubscribedSuccess": "已取消订阅!",
|
||||
"unsubscribeCompletely": "取消订阅",
|
||||
"subscribeToEmails": "订阅邮件",
|
||||
"disclaimer": "你可以在此管理邮件偏好设置,订阅状态会实时更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "发生错误"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到这封邮件是因为你订阅了 {projectName} 的邮件。如果你不想再收到这类邮件,请",
|
||||
"updatePreferences": "更新你的偏好设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消訂閱",
|
||||
"description": "確定要取消 {email} 的電郵訂閱嗎?",
|
||||
"button": "取消訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"managePreferences": "改為管理偏好設定",
|
||||
"successTitle": "已取消訂閱",
|
||||
"successDescription": "{email} 已取消訂閱,你將不會再收到我們的電郵。",
|
||||
"changedMind": "改變主意了嗎?",
|
||||
"subscribeAgain": "重新訂閱"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "訂閱最新消息",
|
||||
"description": "要為 {email} 訂閱電郵嗎?",
|
||||
"button": "訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"successTitle": "訂閱成功!",
|
||||
"successDescription": "{email} 已訂閱,你將會收到我們的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好設定",
|
||||
"description": "管理 {email} 的電郵偏好設定",
|
||||
"subscriptionLabel": "電郵訂閱",
|
||||
"subscribedStatus": "目前已訂閱,會收到電郵通知",
|
||||
"unsubscribedStatus": "目前已取消訂閱",
|
||||
"subscribedSuccess": "訂閱成功!",
|
||||
"unsubscribedSuccess": "已取消訂閱!",
|
||||
"unsubscribeCompletely": "取消訂閱",
|
||||
"subscribeToEmails": "訂閱電郵",
|
||||
"disclaimer": "你可以在此管理電郵偏好設定,訂閱狀態會即時更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中...",
|
||||
"error": "發生錯誤"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到這封電郵是因為你訂閱了 {projectName} 的電郵。如果你不想再收到這類電郵,請",
|
||||
"updatePreferences": "更新你的偏好設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"pages": {
|
||||
"unsubscribe": {
|
||||
"title": "取消訂閱",
|
||||
"description": "確定要取消 {email} 的郵件訂閱嗎?",
|
||||
"button": "取消訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"managePreferences": "改為管理偏好設定",
|
||||
"successTitle": "已取消訂閱",
|
||||
"successDescription": "{email} 已取消訂閱,你將不會再收到我們的郵件。",
|
||||
"changedMind": "改變主意了嗎?",
|
||||
"subscribeAgain": "重新訂閱"
|
||||
},
|
||||
"subscribe": {
|
||||
"title": "訂閱最新消息",
|
||||
"description": "要為 {email} 訂閱郵件嗎?",
|
||||
"button": "訂閱",
|
||||
"buttonLoading": "處理中...",
|
||||
"successTitle": "訂閱成功!",
|
||||
"successDescription": "{email} 已訂閱,你將會收到我們的最新消息。"
|
||||
},
|
||||
"manage": {
|
||||
"title": "管理偏好設定",
|
||||
"description": "管理 {email} 的郵件偏好設定",
|
||||
"subscriptionLabel": "郵件訂閱",
|
||||
"subscribedStatus": "目前已訂閱,會收到郵件通知",
|
||||
"unsubscribedStatus": "目前已取消訂閱",
|
||||
"subscribedSuccess": "訂閱成功!",
|
||||
"unsubscribedSuccess": "已取消訂閱!",
|
||||
"unsubscribeCompletely": "取消訂閱",
|
||||
"subscribeToEmails": "訂閱郵件",
|
||||
"disclaimer": "你可以在此管理郵件偏好設定,訂閱狀態會即時更新。"
|
||||
},
|
||||
"common": {
|
||||
"loading": "載入中...",
|
||||
"error": "發生錯誤"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"footer": {
|
||||
"unsubscribeText": "你收到這封信是因為你訂閱了 {projectName} 的郵件。如果你不想再收到這類郵件,請",
|
||||
"updatePreferences": "更新你的偏好設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -260,22 +259,52 @@ export const WorkflowStepConfigSchemas = {
|
||||
eventName: z.string().min(1),
|
||||
timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(),
|
||||
}),
|
||||
condition: z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
condition: z.union([
|
||||
// Legacy binary condition (if/else)
|
||||
z.object({
|
||||
field: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
// Multi-branch condition (switch/case)
|
||||
z.object({
|
||||
mode: z.literal('multi'),
|
||||
field: z.string().min(1),
|
||||
branches: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
operator: z.enum([
|
||||
'equals',
|
||||
'notEquals',
|
||||
'contains',
|
||||
'notContains',
|
||||
'greaterThan',
|
||||
'lessThan',
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'exists',
|
||||
'notExists',
|
||||
]),
|
||||
value: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
}),
|
||||
]),
|
||||
webhook: z.object({
|
||||
url: z.string().url(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),
|
||||
@@ -319,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(),
|
||||
@@ -334,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(),
|
||||
@@ -370,7 +401,7 @@ export const ActionSchemas = {
|
||||
]),
|
||||
),
|
||||
]),
|
||||
subject: z.string().min(1).max(998).optional(),
|
||||
subject: z.string().min(1).max(998).regex(/^[^\r\n]*$/, 'Subject contains invalid characters').optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
template: uuid.optional(),
|
||||
subscribed: z.boolean().optional(),
|
||||
@@ -381,7 +412,7 @@ export const ActionSchemas = {
|
||||
email, // Simple email string (backward compatible)
|
||||
z.object({
|
||||
// Object with name and email
|
||||
name: z.string().optional(),
|
||||
name: z.string().regex(/^[^\r\n]*$/, 'Name contains invalid characters').optional(),
|
||||
email: email,
|
||||
}),
|
||||
],
|
||||
@@ -399,12 +430,17 @@ export const ActionSchemas = {
|
||||
)
|
||||
.optional(),
|
||||
reply: email.optional(),
|
||||
headers: z.record(z.string().max(998)).optional(),
|
||||
headers: z
|
||||
.record(
|
||||
z.string().regex(/^[^\r\n]+$/, 'Header key contains invalid characters'),
|
||||
z.string().max(998).regex(/^[^\r\n]*$/, 'Header value contains invalid characters'),
|
||||
)
|
||||
.optional(),
|
||||
data: jsonSchema.optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
filename: z.string().min(1).max(255).regex(/^[^\r\n"]+$/, 'Filename contains invalid characters'),
|
||||
content: z.string().min(1), // Base64 encoded file content
|
||||
contentType: z.string().min(1).max(255),
|
||||
contentId: z
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user