Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 5e66203b42 build(deps-dev): bump postcss from 8.5.6 to 8.5.10
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 09:10:30 +00:00
Dries Augustyns 735acff454 feat: add sanitize-html for improved email content sanitization 2026-05-06 11:06:50 +02:00
Dries Augustyns 1193d6c7e6 log: remove detailed logging of full SES notification for inbound email 2026-05-06 10:46:47 +02:00
Dries Augustyns 62661ae45f log: improve logging for inbound email notifications and enhance email content parsing 2026-05-06 10:37:39 +02:00
Dries Augustyns 4587e9d670 fix: enhance email content parsing and logging for better debugging 2026-05-05 20:35:36 +02:00
Dries Augustyns 88a8a02e53 ui: add SVG favicon and update favicon links for consistency 2026-05-05 08:34:41 +02:00
Dries Augustyns cbb3bffcf4 fix: update middleware matcher to exclude webmanifest files 2026-05-05 08:25:51 +02:00
Dries Augustyns 80ef65e04b docs: update README to clarify self-hosted alternative and add inbound emails feature 2026-05-04 21:15:54 +02:00
Dries Augustyns 361ec0b1eb refactor: convert forwardRef components to function components for consistency 2026-05-04 21:08:14 +02:00
Dries Augustyns ed9027b4ef refactor: implement step dialog components for workflow editing 2026-05-04 20:25:19 +02:00
Dries Augustyns d1f357c300 fix: update segment filter logic to retain value and unit, enhance activity name mapping 2026-05-04 19:39:32 +02:00
Dries Augustyns a126563542 fix: update segment filter logic to retain value and unit, enhance activity name mapping 2026-05-04 19:35:29 +02:00
Dries Augustyns a3cc62213f feat: add segment membership operators and enhance segment filter functionality 2026-05-04 19:07:04 +02:00
Dries Augustyns 2d05c3fbc1 refactor: remove unused .png files 2026-05-04 18:17:31 +02:00
Dries Augustyns c0752e1363 ui: refine README introduction for clarity and conciseness 2026-05-04 18:16:30 +02:00
Dries Augustyns 1b09ec1e13 ui: enhance Open Graph image dimensions and styling for improved social sharing 2026-05-04 18:14:55 +02:00
Dries Augustyns 0f00ca1b8c feat: add 'notTriggeredWithin' operator to segment filters for enhanced event tracking 2026-05-04 18:04:24 +02:00
Dries Augustyns 7615b62945 fix: update not found handling in GET route to return 404 response 2026-05-04 14:09:19 +02:00
Dries Augustyns ba23a1c6fa fix: replace font loading method with utility function for improved performance 2026-05-04 12:45:15 +02:00
Dries Augustyns 4b5815c1d6 ui: update Open Graph and Twitter card images for various pages to enhance social sharing 2026-05-04 12:32:51 +02:00
Dries Augustyns 29883ffc71 fix: enhance project name validation to exclude invisible and decorative characters 2026-05-03 08:14:18 +02:00
Dries Augustyns 5724ab9536 feat: add project switching functionality to command palette 2026-05-02 16:28:25 +02:00
Dries Augustyns 0c15cc84df seo: update descriptions for Brevo, Customer.io, and other email alternatives to enhance clarity and appeal 2026-05-02 15:02:23 +02:00
Dries Augustyns a729789922 seo: add spam checker tool with analysis for email content and subject lines 2026-05-02 14:03:58 +02:00
Dries Augustyns 26ec5864ae Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-05-01 21:55:45 +02:00
Dries Augustyns 48425d1df1 feat: implement early fraud warning handling in webhooks 2026-05-01 21:55:32 +02:00
123 changed files with 4368 additions and 3030 deletions
+16 -7
View File
@@ -1,4 +1,4 @@
![card.png](/assets/card.png)
![card.png](https://www.useplunk.com/api/og?title=Open-Source%0AEmail%20Platform&description=%240.001+per+email.+No+contact+limits.+Free+to+self-host.)
<h1 align="center">Plunk</h1>
@@ -15,23 +15,27 @@
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
<a href="https://docs.useplunk.com"><img src="https://img.shields.io/badge/docs-docs.useplunk.com-black" alt="Documentation"/></a>
<a href="https://useplunk.com/discord"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
</p>
## Introduction
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
applications.
It can be considered as a self-hosted alternative to services
like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
Transactional emails, marketing campaigns, and workflow automation — in one platform. Self-hostable, $0.001 per email, no contact limits.
An open-source, self-hosted alternative to tools [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
## Features
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
- **SMTP**: Use Plunk as an SMTP relay to send emails from any existing tool or framework
- **Campaigns**: Send newsletters and product updates to large audiences
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
- **Segments**: Organize contacts with dynamic filtering and target the right audience
- **Contact Management**: Manage contacts with custom fields and full activity history
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
- **Inbound Emails**: Receive and process incoming emails with custom routing rules
## Sponsors
@@ -46,6 +50,11 @@ You can pull the latest image from [Github](https://github.com/useplunk/plunk/pk
A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
## Community
- **Documentation**: [docs.useplunk.com](https://docs.useplunk.com)
- **Discord**: [useplunk.com/discord](https://useplunk.com/discord)
## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
+2
View File
@@ -39,6 +39,7 @@
"mailparser": "^3.9.8",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"sanitize-html": "^2.17.3",
"signale": "^1.4.0",
"stripe": "^20.0.0"
},
@@ -52,6 +53,7 @@
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/sanitize-html": "^2.16.1",
"@types/signale": "^1.4.7",
"concurrently": "^9.2.1",
"tsx": "^4.20.6"
+105 -6
View File
@@ -3,6 +3,7 @@ import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import {simpleParser} from 'mailparser';
import sanitizeHtml from 'sanitize-html';
import signale from 'signale';
import type Stripe from 'stripe';
@@ -157,15 +158,33 @@ export class Webhooks {
// Parse email content if available
let htmlBody: string | undefined;
if (body.content) {
if (body.content && typeof body.content === 'string') {
try {
const parsed = await simpleParser(body.content);
// Prefer HTML body, fallback to text if no HTML available
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
signale.info('[WEBHOOK] Email content parsed successfully');
const isBase64 = body.receipt?.action?.encoding === 'BASE64';
const emailBuffer = isBase64
? Buffer.from(body.content, 'base64')
: Buffer.from(body.content);
const parsed = await simpleParser(emailBuffer);
const raw =
(parsed.html ? String(parsed.html) : undefined) ??
parsed.textAsHtml ??
parsed.text ??
undefined;
if (raw) {
htmlBody = sanitizeHtml(raw, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style'],
},
allowedSchemes: ['http', 'https', 'mailto'],
});
}
} catch (parseError) {
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
// Continue processing without content
}
}
@@ -652,6 +671,86 @@ export class Webhooks {
break;
}
case 'radar.early_fraud_warning.created': {
const warning = event.data.object as Stripe.Radar.EarlyFraudWarning;
const chargeId = typeof warning.charge === 'string' ? warning.charge : warning.charge?.id;
if (!chargeId) {
signale.warn('[WEBHOOK] radar.early_fraud_warning.created missing charge ID');
break;
}
signale.warn(`[WEBHOOK] Early fraud warning received for charge ${chargeId} (${warning.fraud_type})`);
// Retrieve the charge to get card fingerprint and customer email
const charge = await stripe.charges.retrieve(chargeId, {
expand: ['payment_method_details', 'billing_details'],
});
const cardFingerprint = charge.payment_method_details?.card?.fingerprint ?? null;
const customerEmail = charge.billing_details?.email ?? null;
// Refund the charge
try {
await stripe.refunds.create({charge: chargeId});
signale.success(`[WEBHOOK] Refunded charge ${chargeId} due to early fraud warning`);
} catch (refundError) {
signale.error(`[WEBHOOK] Failed to refund charge ${chargeId}:`, refundError);
}
// Add card fingerprint and email to Stripe Radar blocklist value lists
if (cardFingerprint) {
try {
const lists = await stripe.radar.valueLists.list({alias: 'blocked_card_fingerprints'});
let listId: string;
const existingList = lists.data[0];
if (existingList) {
listId = existingList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_card_fingerprints',
name: 'Blocked Card Fingerprints',
item_type: 'card_fingerprint',
});
listId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: listId, value: cardFingerprint});
signale.success(`[WEBHOOK] Added card fingerprint ${cardFingerprint} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add card fingerprint to Radar blocklist:`, blocklistError);
}
}
if (customerEmail) {
try {
const emailLists = await stripe.radar.valueLists.list({alias: 'blocked_emails'});
let emailListId: string;
const existingEmailList = emailLists.data[0];
if (existingEmailList) {
emailListId = existingEmailList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_emails',
name: 'Blocked Emails',
item_type: 'email',
});
emailListId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: emailListId, value: customerEmail});
signale.success(`[WEBHOOK] Added email ${customerEmail} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add email to Radar blocklist:`, blocklistError);
}
}
await NtfyService.notifyEarlyFraudWarning(chargeId, warning.fraud_type, cardFingerprint, customerEmail);
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
+2 -2
View File
@@ -780,7 +780,7 @@ export class CampaignService {
}
// Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition);
const segmentWhere = await SegmentService.buildConditionClause(condition);
return {
...baseWhere,
@@ -823,7 +823,7 @@ export class CampaignService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
const segmentWhere = await SegmentService.buildConditionClause(condition);
return {
...baseWhere,
+22
View File
@@ -799,6 +799,28 @@ export class NtfyService {
});
}
public static async notifyEarlyFraudWarning(
chargeId: string,
fraudType: string,
cardFingerprint: string | null,
customerEmail: string | null,
): Promise<void> {
const details = [
`Charge: ${chargeId}`,
`Fraud type: ${fraudType}`,
cardFingerprint ? `Card fingerprint: ${cardFingerprint}` : null,
customerEmail ? `Customer: ${customerEmail}` : null,
]
.filter(Boolean)
.join(' | ');
await this.sendUrgent('Stripe Early Fraud Warning', `Refunded and blocklisted. ${details}`, [
NtfyTag.SHIELD,
NtfyTag.MONEY,
NtfyTag.SKULL,
]);
}
/**
* Initialize the ntfy service with the configured URL
*/
+162 -16
View File
@@ -91,7 +91,7 @@ export class SegmentService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
const [contacts, total] = await Promise.all([
prisma.contact.findMany({
@@ -137,7 +137,7 @@ export class SegmentService {
this.validateCondition(data.condition);
// Compute initial member count
const where = this.buildWhereClause(projectId, data.condition);
const where = await this.buildWhereClause(projectId, data.condition);
memberCount = await prisma.contact.count({where});
conditionJson = toPrismaJson(data.condition);
}
@@ -192,10 +192,14 @@ export class SegmentService {
if (data.condition !== undefined && existing.type !== 'STATIC') {
// Validate condition if provided (only for DYNAMIC segments)
this.validateCondition(data.condition);
if (this.getReferencedSegmentIds(data.condition).has(segmentId)) {
throw new HttpException(400, 'A segment cannot reference itself');
}
updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition);
const where = await this.buildWhereClause(projectId, data.condition);
updateData.memberCount = await prisma.contact.count({where});
}
if (data.trackMembership !== undefined) {
@@ -267,7 +271,7 @@ export class SegmentService {
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
@@ -305,7 +309,7 @@ export class SegmentService {
});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
@@ -460,7 +464,7 @@ export class SegmentService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues
const BATCH_SIZE = 1000;
@@ -629,9 +633,57 @@ export class SegmentService {
/**
* Build a single filter condition
*/
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
public static async buildFilterCondition(
filter: SegmentFilter,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const {field, operator, value, unit} = filter;
// Handle segment membership filters (e.g., "segment.<segmentId>")
if (field.startsWith('segment.')) {
const segmentId = field.substring(8);
if (visitedSegments.has(segmentId)) {
throw new HttpException(400, 'Circular segment reference detected');
}
const referencedSegment = await prisma.segment.findUnique({
where: {id: segmentId},
});
if (!referencedSegment) {
throw new HttpException(400, `Referenced segment not found: ${segmentId}`);
}
let memberIds: string[];
if (referencedSegment.type === 'STATIC' || referencedSegment.trackMembership) {
// Use the membership table for static or tracked segments
const memberships = await prisma.segmentMembership.findMany({
where: {segmentId, exitedAt: null},
select: {contactId: true},
});
memberIds = memberships.map(m => m.contactId);
} else {
// For untracked dynamic segments, evaluate the condition recursively
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
const nextVisited = new Set(visitedSegments).add(segmentId);
const nestedWhere = await this.buildConditionClause(nestedCondition, nextVisited);
if (operator === 'memberOfSegment') {
return nestedWhere;
}
return {NOT: nestedWhere};
}
if (operator === 'memberOfSegment') {
return {id: {in: memberIds}};
}
return {id: {notIn: memberIds}};
}
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
if (field.startsWith('event.')) {
const eventName = field.substring(6); // Remove "event." prefix
@@ -664,6 +716,26 @@ export class SegmentService {
}
}
/**
* Collect all segment IDs directly referenced in a condition (non-recursive DB lookup)
*/
private static getReferencedSegmentIds(condition: FilterCondition): Set<string> {
const ids = new Set<string>();
for (const group of condition.groups) {
for (const filter of group.filters) {
if (filter.field.startsWith('segment.')) {
ids.add(filter.field.substring(8));
}
}
if (group.conditions) {
for (const id of this.getReferencedSegmentIds(group.conditions)) {
ids.add(id);
}
}
}
return ids;
}
/**
* Validate segment condition (recursive)
*/
@@ -688,8 +760,13 @@ export class SegmentService {
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
public static async buildConditionClause(
condition: FilterCondition,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const groupClauses = await Promise.all(
condition.groups.map(group => this.buildGroupClause(group, visitedSegments)),
);
if (condition.logic === 'AND') {
return {AND: groupClauses};
@@ -758,12 +835,23 @@ export class SegmentService {
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
'notTriggeredWithin',
'memberOfSegment',
'notMemberOfSegment',
];
if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
}
// Segment membership operators use the segmentId encoded in the field name, no separate value needed
if (filter.operator === 'memberOfSegment' || filter.operator === 'notMemberOfSegment') {
if (!filter.field.startsWith('segment.')) {
throw new HttpException(400, 'memberOfSegment/notMemberOfSegment operators require a segment field (segment.<id>)');
}
return;
}
// Validate that operators that need a value have one
const operatorsNeedingValue = [
'equals',
@@ -778,6 +866,7 @@ export class SegmentService {
'olderThan',
'triggeredWithin',
'triggeredOlderThan',
'notTriggeredWithin',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
@@ -785,7 +874,7 @@ export class SegmentService {
}
// Validate unit for time-based operators
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
}
}
@@ -793,27 +882,36 @@ export class SegmentService {
/**
* Build Prisma where clause from filter condition (entry point)
*/
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
private static async buildWhereClause(
projectId: string,
condition: FilterCondition,
): Promise<Prisma.ContactWhereInput> {
return {
projectId,
...this.buildConditionClause(condition),
...(await this.buildConditionClause(condition)),
};
}
/**
* Build Prisma clause from filter group (recursive)
*/
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
private static async buildGroupClause(
group: FilterGroup,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const clauses: Prisma.ContactWhereInput[] = [];
// Add filter conditions from this group
if (group.filters.length > 0) {
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
const filterClauses = await Promise.all(
group.filters.map(filter => this.buildFilterCondition(filter, visitedSegments)),
);
clauses.push(...filterClauses);
}
// Add nested condition if present
if (group.conditions) {
clauses.push(this.buildConditionClause(group.conditions));
clauses.push(await this.buildConditionClause(group.conditions, visitedSegments));
}
// All conditions within a group are combined with AND
@@ -1166,6 +1264,28 @@ export class SegmentService {
},
};
case 'notTriggeredWithin': {
// Contact has not triggered this event within the timeframe (includes never-triggered contacts)
if (!unit) {
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
events: {
none: {
name: eventName,
createdAt: {
gte: since,
},
},
},
};
}
default:
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
}
@@ -1181,13 +1301,18 @@ export class SegmentService {
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
// Map activity names to Email model fields
// Map activity names to Email model fields (accept both verb and past-tense forms)
const fieldMap: Record<string, string> = {
open: 'openedAt',
opened: 'openedAt',
click: 'clickedAt',
clicked: 'clickedAt',
bounce: 'bouncedAt',
bounced: 'bouncedAt',
complaint: 'complainedAt',
complained: 'complainedAt',
sent: 'sentAt',
delivery: 'deliveredAt',
delivered: 'deliveredAt',
};
@@ -1278,6 +1403,27 @@ export class SegmentService {
},
};
case 'notTriggeredWithin': {
// Contact has not had this email activity within the timeframe (includes contacts with no activity)
if (!unit) {
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
emails: {
none: {
[field]: {
gte: since,
},
},
},
};
}
default:
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
}
+1 -1
View File
@@ -44,7 +44,7 @@
"eslint": "^9.27.0",
"eslint-config-next": "^16.0.1",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,4 +1,5 @@
import {Footer, Navbar} from '../';
import {FAQSection, Footer, Navbar} from '../';
import type {FAQ} from '../FAQSection';
import {motion} from 'framer-motion';
import React, {ReactNode, useLayoutEffect, useState} from 'react';
import Link from 'next/link';
@@ -13,6 +14,7 @@ interface GuideLayoutProps {
children: ReactNode;
canonical?: string;
ogImage?: string;
faqs?: FAQ[];
}
/**
@@ -25,8 +27,12 @@ export function GuideLayout({
readTime,
children,
canonical,
ogImage = 'https://www.useplunk.com/assets/card.png',
ogImage,
faqs,
}: GuideLayoutProps) {
const resolvedOgImage =
ogImage ||
`https://www.useplunk.com/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&tag=Guide`;
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
@@ -90,7 +96,7 @@ export function GuideLayout({
description: description,
url: canonical,
type: 'article',
images: [{url: ogImage, alt: title}],
images: [{url: resolvedOgImage, alt: title, width: 1200, height: 630}],
article: {
publishedTime: lastUpdated,
modifiedTime: lastUpdated,
@@ -103,7 +109,7 @@ export function GuideLayout({
type="Article"
url={canonical || ''}
title={title}
images={[ogImage]}
images={[resolvedOgImage]}
datePublished={lastUpdated}
dateModified={lastUpdated}
authorName="Plunk"
@@ -241,6 +247,13 @@ export function GuideLayout({
</div>
</main>
{faqs && faqs.length > 0 && (
<FAQSection
faqs={faqs}
schemaId={`faq-${canonical ? canonical.split('/').pop() : 'guide'}`}
/>
)}
<Footer />
</>
);
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -63,5 +63,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt)).*)',],
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
};
+8 -1
View File
@@ -82,7 +82,14 @@ 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://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
images: [
{
url: `https://www.useplunk.com/api/og?title=${encodeURIComponent('The Open-Source Email Platform')}`,
width: 1200,
height: 630,
alt: 'Plunk',
},
],
}}
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
/>
+2 -1
View File
@@ -7,12 +7,13 @@ export default class MyDocument extends Document {
<Html lang="en">
<Head>
{/* Start favicon */}
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<meta name="theme-color" content="#171717" />
+139
View File
@@ -0,0 +1,139 @@
import {ImageResponse} from 'next/og';
import type {NextRequest} from 'next/server';
import {getBricolageFont} from '../../lib/og-font';
export const config = {runtime: 'edge'};
const PLUNK_P_PATH =
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
export default function handler(req: NextRequest) {
const {searchParams} = new URL(req.url);
const title = searchParams.get('title') || 'The Open-Source Email Platform';
const description = searchParams.get('description') || '';
const tag = searchParams.get('tag') || '';
const fontData = getBricolageFont();
const titleLength = title.length;
const fontSize = titleLength < 35 ? 144 : titleLength < 55 ? 120 : titleLength < 75 ? 100 : 84;
return new ImageResponse(
(
<div
style={{
width: '2400px',
height: '1260px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '128px 144px',
backgroundColor: '#ffffff',
fontFamily: '"Bricolage Grotesque"',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Ghost P watermark */}
<div
style={{
position: 'absolute',
bottom: '-120px',
right: '-60px',
opacity: 0.035,
display: 'flex',
}}
>
<svg width="960" height="960" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#000000" />
</svg>
</div>
{/* Top row: logo + URL */}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '20px'}}>
<svg width="60" height="60" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#171717" />
</svg>
<span
style={{
fontSize: '44px',
fontWeight: 800,
color: '#171717',
letterSpacing: '-0.03em',
}}
>
Plunk
</span>
</div>
<span style={{fontSize: '30px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
useplunk.com
</span>
</div>
{/* Main content */}
<div style={{display: 'flex', flexDirection: 'column', gap: '36px', maxWidth: '1840px'}}>
{tag ? (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 28px',
backgroundColor: '#f4f4f5',
borderRadius: '12px',
fontSize: '30px',
color: '#52525b',
fontWeight: 600,
width: 'fit-content',
letterSpacing: '-0.01em',
}}
>
{tag}
</div>
) : null}
<div
style={{
fontSize: `${fontSize}px`,
fontWeight: 800,
color: '#0a0a0a',
lineHeight: 1.08,
letterSpacing: '-0.04em',
whiteSpace: 'pre-wrap',
}}
>
{title}
</div>
{description ? (
<div
style={{
fontSize: '44px',
color: '#737373',
lineHeight: 1.5,
fontWeight: 400,
letterSpacing: '-0.01em',
}}
>
{description}
</div>
) : null}
</div>
{/* Bottom spacer */}
<div style={{display: 'flex'}} />
</div>
),
{
width: 2400,
height: 1260,
fonts: [
{
name: 'Bricolage Grotesque',
data: fontData,
style: 'normal',
weight: 800,
},
],
},
);
}
@@ -79,6 +79,9 @@ export default function EmailEditorFeature() {
property="og:description"
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
</Head>
<Navbar />
@@ -78,6 +78,9 @@ export default function InboundEmailFeature() {
property="og:description"
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
</Head>
<Navbar />
@@ -93,6 +93,9 @@ export default function SegmentsFeature() {
property="og:description"
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
</Head>
<Navbar />
+3
View File
@@ -89,6 +89,9 @@ export default function SMTPFeature() {
property="og:description"
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
</Head>
<Navbar />
@@ -78,6 +78,9 @@ export default function WorkflowsFeature() {
property="og:description"
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
</Head>
<Navbar />
@@ -6,8 +6,8 @@ import Link from 'next/link';
export default function EmailAPIGuide() {
return (
<GuideLayout
title="Email API Guide: Everything You Need to Know with Code Examples"
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
title="What is an Email API? Complete Guide with Code Examples | Plunk"
description="An email API lets you send, receive, and manage emails programmatically. Learn how they work, see code examples in Node.js, Python, PHP, Ruby, and Go, and choose the right provider."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-api-guide"
@@ -2,6 +2,35 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is a good email bounce rate?',
answer:
'A bounce rate below 2% is considered excellent. Between 25% is concerning and needs attention. Above 5% is critical and will seriously damage your sender reputation and deliverability. For hard bounces specifically, keep this below 0.5% per campaign. Most reputable email platforms will suspend accounts or pause sending if hard bounce rates exceed 510%.',
},
{
question: 'What is the difference between a hard bounce and a soft bounce?',
answer:
'A hard bounce is a permanent delivery failure—the email address does not exist, the domain is invalid, or the receiving server has permanently blocked delivery. Remove hard bounce addresses immediately and never send to them again. A soft bounce is a temporary failure—the mailbox is full, the server is temporarily down, or the email is too large. Email platforms typically retry soft bounces automatically for 2472 hours.',
},
{
question: 'How do I reduce email bounce rates?',
answer:
'Key strategies to reduce bounces: (1) Use double opt-in to confirm email addresses at signup, (2) Validate email addresses in real-time on forms to catch typos and invalid formats, (3) Remove hard bounces immediately and automatically, (4) Clean your list every 612 months to remove stale addresses (1520% of email addresses go invalid per year), (5) Never purchase email lists—they have 2040% invalid addresses, and (6) Maintain consistent sending to prevent addresses going stale.',
},
{
question: 'Do bounces affect my email deliverability?',
answer:
'Yes, high bounce rates directly damage your sender reputation. Email providers like Gmail and Outlook track bounce rates as a signal of list quality and sending practices. High bounce rates cause more of your emails—even to valid addresses—to land in spam. Persistent high bounce rates can lead to your sending IP being blacklisted, making it extremely difficult to deliver any emails.',
},
{
question: 'Why are my emails bouncing suddenly?',
answer:
'Sudden increases in bounce rates can be caused by: sending to an old list that has gone stale (addresses expire over time), sending to a purchased list, a domain or server configuration change, your sending IP or domain being blacklisted, or reaching inbox provider sending limits. Identify the pattern—are bounces happening on specific domains or across all recipients?—then investigate the root cause.',
},
];
export default function EmailBounceRate() {
return (
@@ -11,6 +40,7 @@ export default function EmailBounceRate() {
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/email-bounce-rate"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is a good email open rate?',
answer:
'A good email open rate depends on your industry and email type. For B2B emails, 25-35% is considered excellent. For B2C marketing emails, 20-30% is strong. Overall industry averages range from 20-30%, though Apple Mail Privacy Protection has inflated reported rates by 5-15 percentage points since 2021. Compare against your own historical data rather than industry averages alone.',
},
{
question: 'What are average B2B email open rates?',
answer:
'Average B2B email open rates typically range from 20-30%, with top-performing campaigns achieving 35-45%. B2B emails tend to outperform B2C because recipients opted in for professional reasons and have higher intent. Industry-specific rates vary: SaaS averages 22-28%, financial services 20-25%, and professional services 25-30%. Transactional B2B emails like invoices and notifications often exceed 50%.',
},
{
question: 'How do I improve my email open rate?',
answer:
'The most impactful ways to improve email open rates are: (1) Write compelling, specific subject lines under 50 characters, (2) Personalize beyond first names—use behavioral data and purchase history, (3) Send at optimal times (TuesdayThursday, 10am2pm for B2B), (4) Segment your list to send relevant content to specific groups, (5) Keep your list clean by removing inactive subscribers, (6) Use a recognizable sender name, and (7) Optimize preview text to complement the subject line.',
},
{
question: 'Why are my email open rates low?',
answer:
'Low email open rates typically result from: weak subject lines that fail to create curiosity or urgency, poor list hygiene with too many inactive subscribers dragging down averages, emails landing in spam due to deliverability or authentication issues, bad send timing, email fatigue from sending too frequently, or poor segmentation sending irrelevant content. Review each factor systematically and A/B test changes one at a time.',
},
{
question: 'Does Apple Mail Privacy Protection affect open rates?',
answer:
'Yes. Apple Mail Privacy Protection (MPP), launched in iOS 15 in 2021, pre-loads email images and tracking pixels, registering "opens" even if the user never actually read the email. This inflates open rates by 5-15 percentage points across the industry. To adapt, focus on click-through rates and conversions as more reliable engagement metrics, and segment by email client to understand true engagement patterns.',
},
];
export default function EmailOpenRate() {
return (
<GuideLayout
title="Email Open Rate: Benchmarks, Strategies & Best Practices"
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
title="Email Open Rates: Industry Benchmarks & How to Improve Yours"
description="Learn what affects email open rates, industry benchmarks by sector, B2B vs B2C averages, and proven tactics to improve your open rates. Complete guide with actionable tips."
lastUpdated="2025-12-20"
readTime="10 min"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-open-rate"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -126,6 +156,86 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</InfoBox>
</section>
{/* B2B Email Open Rates */}
<section id="b2b-email-open-rates" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">B2B Email Open Rates</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
B2B (business-to-business) emails consistently outperform B2C benchmarks. Recipients opted in because of
genuine professional need, making them more engaged audiences.
</p>
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
<table className="w-full">
<thead>
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold">B2B Industry</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Average Open Rate</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Top Performers</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">SaaS / Technology</td>
<td className="px-6 py-4 text-sm text-neutral-700">2228%</td>
<td className="px-6 py-4 text-sm text-neutral-700">35%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Professional Services</td>
<td className="px-6 py-4 text-sm text-neutral-700">2532%</td>
<td className="px-6 py-4 text-sm text-neutral-700">40%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Financial Services</td>
<td className="px-6 py-4 text-sm text-neutral-700">2026%</td>
<td className="px-6 py-4 text-sm text-neutral-700">33%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Agency / Marketing</td>
<td className="px-6 py-4 text-sm text-neutral-700">1824%</td>
<td className="px-6 py-4 text-sm text-neutral-700">30%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Transactional / Receipts</td>
<td className="px-6 py-4 text-sm text-neutral-700">4565%</td>
<td className="px-6 py-4 text-sm text-neutral-700">70%+</td>
</tr>
</tbody>
</table>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-6">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B-Specific Tactics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>• Send TuesdayThursday, 10am2pm in recipient's timezone</li>
<li> Use company/product name in subject line for recognition</li>
<li> Personalize with job title, industry, or company size</li>
<li> Reference specific pain points relevant to their role</li>
<li> Keep frequency lower12 times per month for cold lists</li>
</ul>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B vs B2C Differences</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> B2B decisions are more consideredlonger subject lines acceptable</li>
<li> B2B audiences check email less frequently on weekends</li>
<li> B2B recipients share inboxes with colleagues, affecting metrics</li>
<li> B2B unsubscribe rates are typically lower (0.10.3% vs 0.30.5%)</li>
<li> B2B transactional emails (invoices, updates) have much higher opens</li>
</ul>
</div>
</div>
<InfoBox type="tip" title="Segment Transactional vs Marketing">
<p>
Keep transactional emails (invoices, notifications, password resets) in separate campaigns from marketing
emails. Transactional emails typically achieve 4060% open rates, which will skew your marketing
benchmarks if mixed together.
</p>
</InfoBox>
</section>
{/* Factors Affecting */}
<section id="factors" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
+1 -1
View File
@@ -112,7 +112,7 @@ export default function GuidesIndex() {
description:
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
url: 'https://www.useplunk.com/guides',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Guides+for+Developers&tag=Guide', alt: 'Plunk Guides', width: 1200, height: 630}],
}}
/>
+145 -2
View File
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is DKIM?',
answer:
'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a cryptographic digital signature to your outgoing emails. It allows receiving mail servers to verify that an email was genuinely sent from your domain and that its content was not altered in transit, improving deliverability and protecting against email spoofing.',
},
{
question: 'What is a DKIM key?',
answer:
'A DKIM key is a cryptographic key pair used to sign and verify emails. The private key is stored securely on your mail server and used to sign outgoing emails. The public key is published as a TXT record in your domain\'s DNS under selector._domainkey.yourdomain.com and is used by receiving servers to verify the signature. You should never share or expose your DKIM private key.',
},
{
question: 'How do I set up DKIM?',
answer:
'Setting up DKIM involves three steps: (1) Generate a DKIM key pair for your domain—most email providers do this automatically, (2) Publish the public key as a TXT record in your DNS at selector._domainkey.yourdomain.com, and (3) Configure your mail server or email service provider to sign outgoing emails with the private key. Platforms like Plunk handle all of this automatically when you add your domain.',
},
{
question: 'What is the difference between DKIM and SPF?',
answer:
'SPF (Sender Policy Framework) verifies that the sending mail server is authorized to send email for your domain by checking its IP address against your DNS record. DKIM adds a cryptographic signature to the email that verifies the content has not been altered in transit. They are complementary: SPF validates the server identity, DKIM validates the message integrity. Using both together—along with DMARC—provides the strongest email authentication.',
},
{
question: 'What happens if DKIM fails?',
answer:
'If DKIM verification fails, the receiving mail server may treat the email as suspicious. Depending on your DMARC policy, failed DKIM emails might be delivered normally (p=none), sent to spam (p=quarantine), or completely rejected (p=reject). Persistent DKIM failures damage your sender reputation and reduce email deliverability. Common causes include misconfigured DNS records, expired keys, or email modification during forwarding.',
},
];
export default function WhatIsDKIM() {
return (
<GuideLayout
title="What is DKIM? Email Authentication Explained"
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
description="Learn how DKIM (DomainKeys Identified Mail) works, what a DKIM key is, and how to set it up to protect your emails from spoofing and improve deliverability."
lastUpdated="2025-12-20"
readTime="8 min"
readTime="10 min"
canonical="https://www.useplunk.com/guides/what-is-dkim"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -300,6 +330,119 @@ export default function WhatIsDKIM() {
</div>
</section>
{/* DKIM vs SPF */}
<section id="dkim-vs-spf" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM vs SPF: What's the Difference?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
DKIM and SPF are both email authentication protocols, but they work differently and check different things:
</p>
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
<table className="w-full">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">Feature</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">DKIM</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">SPF</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">What it validates</td>
<td className="px-6 py-4 text-sm text-neutral-700">Email content integrity</td>
<td className="px-6 py-4 text-sm text-neutral-700">Sending server authorization</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">How it works</td>
<td className="px-6 py-4 text-sm text-neutral-700">Cryptographic signature in headers</td>
<td className="px-6 py-4 text-sm text-neutral-700">IP address check against DNS list</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Survives forwarding</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (if content unchanged)</td>
<td className="px-6 py-4 text-sm text-neutral-700">No (forwarded IP changes)</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">DNS record type</td>
<td className="px-6 py-4 text-sm text-neutral-700">TXT at selector._domainkey.*</td>
<td className="px-6 py-4 text-sm text-neutral-700">TXT at root domain</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Required for DMARC</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
</tr>
</tbody>
</table>
</div>
<InfoBox type="tip" title="Use Both for Maximum Protection">
<p>
DKIM and SPF complement each other. SPF covers scenarios where DKIM can't (like forged server IPs), and DKIM
covers forwarding scenarios where SPF breaks. Implementing bothplus DMARCgives you complete email
authentication coverage.
</p>
</InfoBox>
</section>
{/* How to Test DKIM */}
<section id="test-dkim" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Test Your DKIM Setup</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
After setting up DKIM, verify it's working correctly using these methods:
</p>
<div className="space-y-6">
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">1. Check the Email Headers</h3>
<p className="text-neutral-700 mb-4">
Send a test email and view the raw message headers. Look for the <code>DKIM-Signature</code> header and the{' '}
<code>Authentication-Results</code> header which shows whether DKIM passed or failed:
</p>
<CodeBlock
language="text"
title="Example Authentication-Results Header"
code={`Authentication-Results: mx.google.com;
dkim=pass header.i=@yourdomain.com header.s=default header.b=GJwP3Qr8;
spf=pass (google.com: domain of you@yourdomain.com designates 1.2.3.4 as permitted sender);
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=yourdomain.com`}
/>
</div>
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">2. Use DNS Lookup Tools</h3>
<p className="text-neutral-700">
Verify your DKIM public key is correctly published in DNS by querying your DKIM TXT record:
</p>
<CodeBlock
language="bash"
title="DNS Lookup Command"
code={`# Check your DKIM record via DNS
dig TXT default._domainkey.yourdomain.com
# Or using nslookup
nslookup -type=TXT default._domainkey.yourdomain.com`}
/>
</div>
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">3. Send to Gmail and Check</h3>
<p className="text-neutral-700">
Send a test email to a Gmail address, then click the three-dot menu and select "Show original". The
"Summary" at the top will show DKIM: PASS or DKIM: FAIL, confirming your setup is working.
</p>
</div>
</div>
<InfoBox type="info" title="Common DKIM Issues">
<p>
If DKIM fails, check that: (1) The DNS record uses the correct selector name, (2) The record hasn't been
truncated by your DNS provider (long keys may need to be split), (3) DNS propagation is complete (can take
up to 48 hours), and (4) Your email service is configured to sign with the correct private key.
</p>
</InfoBox>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Authentication Guides</h2>
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is DMARC?',
answer:
'DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an email authentication protocol that builds on SPF and DKIM to protect your domain from spoofing and phishing. It tells receiving mail servers what to do when an email fails authentication checks—none (monitor), quarantine (spam), or reject (block)—and provides detailed reports about who is sending email using your domain.',
},
{
question: 'What is a DMARC policy?',
answer:
'A DMARC policy (the "p=" tag) determines how receiving mail servers handle emails that fail authentication. p=none means monitor only and take no action; p=quarantine sends failed emails to spam; p=reject completely blocks failed emails. Start with p=none to gather data, then progressively tighten to quarantine and reject as you confirm all your legitimate senders are properly authenticated.',
},
{
question: 'What does p=reject mean in DMARC?',
answer:
'p=reject is the strictest DMARC policy. Emails that fail DMARC authentication are completely rejected and not delivered to the recipient. This provides maximum protection against phishing and spoofing, but requires that all your legitimate email sources (newsletters, CRMs, transactional tools) are properly configured with SPF and DKIM before enabling it.',
},
{
question: 'How do I add a DMARC record?',
answer:
'Add a TXT record in your DNS with the name _dmarc.yourdomain.com. Start with a monitoring-only value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com. This records email activity without affecting delivery. After reviewing reports for 2-4 weeks and confirming your SPF and DKIM setup, gradually strengthen to p=quarantine then p=reject.',
},
{
question: 'Is DMARC required for email deliverability?',
answer:
'Since February 2024, Google and Yahoo require DMARC authentication (with at least p=none) for bulk senders sending more than 5,000 emails per day to Gmail or Yahoo addresses. While not strictly required for smaller senders, implementing DMARC is strongly recommended for all domains to improve deliverability, prevent phishing, and meet industry best practices.',
},
];
export default function WhatIsDMARC() {
return (
<GuideLayout
title="What is DMARC? Email Policy & Reporting Explained"
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
title="What is DMARC? Policy, Setup & Reporting Explained"
description="Learn how DMARC works with SPF and DKIM to protect your domain. Understand DMARC policies (none, quarantine, reject), how to set up a DMARC record, and read reports."
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/what-is-dmarc"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -2,6 +2,35 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is SPF in email?',
answer:
'SPF (Sender Policy Framework) is an email authentication protocol that lets domain owners specify which mail servers are authorized to send email on behalf of their domain. It works by publishing a list of authorized IP addresses in your DNS as a TXT record. When an email arrives, the receiving server checks if the sending server\'s IP address is on your approved list.',
},
{
question: 'What is the difference between SPF and DKIM?',
answer:
'SPF verifies that the sending mail server is authorized to send from your domain by checking the server\'s IP address against your DNS record. DKIM adds a cryptographic signature to the email body and headers, verifying the content was not modified in transit. SPF validates who is sending; DKIM validates what was sent. Both work best together, and DMARC requires at least one of them to be properly configured.',
},
{
question: 'How do I create an SPF record?',
answer:
'Add a TXT record to your domain\'s DNS at the root domain (@ or yourdomain.com) with the format: v=spf1 include:[your-email-service] ~all. Replace the include: with the SPF value from your email provider. If you use multiple email services, combine them in one record: v=spf1 include:_spf.google.com include:spf.useplunk.com ~all. You can only have one SPF record per domain.',
},
{
question: 'What does ~all mean in an SPF record?',
answer:
'~all (tilde-all) at the end of an SPF record is a "soft fail" qualifier, meaning emails from unlisted servers should be accepted but flagged as potentially suspicious. The alternative -all (hard fail) completely rejects emails from unlisted servers. Most experts recommend ~all for initial setup to avoid blocking legitimate emails. Never use +all—it passes all emails and completely defeats SPF\'s purpose.',
},
{
question: 'Why is SPF failing even though I set it up correctly?',
answer:
'Common SPF failure causes: (1) You have multiple SPF records—you can only have one, combine all senders into a single record, (2) You exceeded the 10 DNS lookup limit—each include: counts as one lookup, (3) You added a new email service but forgot to add its SPF include, (4) DNS propagation is still in progress—can take up to 48 hours, (5) Your email is being forwarded, which changes the sending IP and breaks SPF (use DKIM too to handle forwarding).',
},
];
export default function WhatIsSPF() {
return (
@@ -11,6 +40,7 @@ export default function WhatIsSPF() {
lastUpdated="2025-12-20"
readTime="7 min"
canonical="https://www.useplunk.com/guides/what-is-spf"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
+18
View File
@@ -3,6 +3,7 @@ import {motion} from 'framer-motion';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import {DASHBOARD_URI, LANDING_URI, WIKI_URI} from '../lib/constants';
import React from 'react';
import {NextSeo} from 'next-seo';
import Artur from '../../public/assets/artur.png';
import Joe from '../../public/assets/joe.png';
import Noah from '../../public/assets/noah.png';
@@ -154,6 +155,23 @@ const tickerItems = [
export default function Index() {
return (
<>
<NextSeo
title="Plunk — Open-Source Transactional Email Platform"
description="Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits."
openGraph={{
title: 'Plunk — Open-Source Transactional Email Platform',
description:
'Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits.',
images: [
{
url: 'https://www.useplunk.com/api/og?title=The+Open-Source+Email+Platform',
width: 1200,
height: 630,
alt: 'Plunk — The Open-Source Email Platform',
},
],
}}
/>
<Script
id={`faq-schema-index`}
type="application/ld+json"
+8
View File
@@ -79,6 +79,14 @@ export default function Pricing() {
title: 'Plunk Pricing | The Open-Source Email Platform',
description:
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month. No hidden fees.',
images: [
{
url: 'https://www.useplunk.com/api/og?title=Simple%2C+Honest+Pricing&description=%240.001+per+email.+No+contact+limits.+Free+to+start.',
width: 1200,
height: 630,
alt: 'Plunk Pricing',
},
],
}}
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
/>
+13 -6
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, ArrowUpRight, Code2, Search} from 'lucide-react';
import {ArrowRight, ArrowUpRight, Code2, Search, ShieldAlert} from 'lucide-react';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
@@ -43,21 +43,28 @@ const tools = [
icon: Search,
number: '02',
},
{
name: 'Spam Checker',
slug: 'spam-checker',
description: 'Test your email subject line and content for spam trigger words and deliverability issues.',
icon: ShieldAlert,
number: '03',
},
];
export default function ToolsIndex() {
return (
<>
<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."
title="Free Email Tools | Spam Checker, Email Validator & More | Plunk"
description="Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required."
canonical="https://www.useplunk.com/tools"
openGraph={{
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
title: 'Free Email Tools | Spam Checker, Email Validator & More | Plunk',
description:
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
'Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
url: 'https://www.useplunk.com/tools',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
}}
/>
@@ -46,15 +46,15 @@ export default function MarkdownToEmail() {
return (
<>
<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."
title="Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk"
description="Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up required."
canonical="https://www.useplunk.com/tools/markdown-to-email"
openGraph={{
title: 'Free Markdown to Email HTML Converter | Plunk',
title: 'Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk',
description:
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
'Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up.',
url: 'https://www.useplunk.com/tools/markdown-to-email',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Markdown+to+Email+HTML+Converter&tag=Tool', alt: 'Plunk Markdown to Email', width: 1200, height: 630}],
}}
/>
@@ -0,0 +1,544 @@
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
import {AlertTriangle, ArrowRight, CheckCircle, ShieldAlert, XCircle} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
const SPAM_TRIGGER_WORDS = [
'act now', 'act immediately', 'action required', 'apply now', 'apply online',
'as seen on', 'avoid bankruptcy', 'be your own boss', 'being a member',
'big bucks', 'bill 1618', 'billion dollars', 'bonus',
'buy direct', 'buy now', 'buyer protection', 'buying judgments',
'call free', 'call now', 'cancel at any time', 'can\'t live without',
'cash bonus', 'cash prize', 'celebrate', 'cent on the dollar',
'chance', 'cheap', 'check now', 'claim now', 'claim your', 'click below',
'click here', 'click now', 'click to remove', 'compare rates',
'compete for your business', 'congratulations', 'consolidate debt',
'copy accurately', 'copy dvds', 'deal', 'dear friend',
'earn per week', 'earn extra cash', 'earn extra income',
'eliminate bad credit', 'eliminate debt', 'excl', 'exclusive deal',
'exclusive offer', 'extra cash', 'extra income',
'f r e e', 'fantastic deal', 'fast cash', 'financial freedom',
'find out anything', 'for free', 'for just $', 'for only $',
'free access', 'free bonus', 'free cell phone', 'free consultation',
'free gift', 'free grant money', 'free info', 'free installation',
'free investment', 'free leads', 'free membership', 'free money',
'free offer', 'free preview', 'free prize', 'free quote', 'free sample',
'free trial', 'freedom', 'full refund', 'get it now', 'get out of debt',
'get paid', 'give it away', 'giving it away', 'great offer',
'guaranteed', 'hidden assets', 'hidden charges',
'home based business', 'hot deal', 'huge discount', 'incredible deal',
'incredible offer', 'information you requested', 'instant access',
'instant approval', 'insurance', 'it\'s effective', 'join millions',
'joining for free', 'junk mail', 'last chance', 'limited time',
'lose weight', 'lowest price', 'lowest rate', 'lucky winner',
'luxury car', 'luxury gift', 'make $', 'make money', 'make money fast',
'marketing solutions', 'mass email', 'meet singles', 'member',
'million dollars', 'miracle', 'money back', 'money making',
'monthly payment', 'mortgage', 'multi level marketing',
'name brand', 'new customer', 'new domain', 'now only',
'obligation free', 'off everything', 'offer expires',
'once in a lifetime', 'one hundred percent free', 'one hundred percent satisfied',
'open an account', 'opportunity', 'opt in', 'order now',
'order status', 'order today', 'outstanding values',
'pennies a day', 'per day', 'per month', 'per week',
'potential earnings', 'price protection', 'prices reduced',
'prize', 'profit', 'promise you', 'pure profit',
'refinance', 'refund', 'register for free', 'remove',
'reverses aging', 'risk free', 'satisfaction guaranteed',
'save big money', 'save up to', 'selected', 'serious cash',
'sign up free', 'special discount', 'special offer', 'special promotion',
'subject to credit', 'subscribe for free', 'super deal', 'supplies are limited',
'take action', 'tens of thousands', 'the best rates',
'time limited', 'trial offer', 'ultimate',
'unbeatable', 'unbelievable', 'urgent', 'valued customer',
'very cheap', 'want to make', 'while supplies last', 'winner',
'work at home', 'work from home', 'you are a winner', 'you have been selected',
'you have been chosen', 'you won', 'you\'ve won', 'your income',
];
interface SpamCheck {
type: 'error' | 'warning' | 'pass';
label: string;
detail: string;
}
interface SpamResult {
score: number;
checks: SpamCheck[];
triggeredWords: string[];
}
function analyzeSpam(subject: string, body: string): SpamResult {
const checks: SpamCheck[] = [];
const triggeredWords: string[] = [];
let deductions = 0;
const fullText = `${subject} ${body}`.toLowerCase();
// Check for spam trigger words
const foundWords = SPAM_TRIGGER_WORDS.filter(word => fullText.includes(word));
if (foundWords.length === 0) {
checks.push({type: 'pass', label: 'No spam trigger words', detail: 'Your content does not contain common spam trigger phrases.'});
} else if (foundWords.length <= 2) {
checks.push({type: 'warning', label: `${foundWords.length} spam trigger word${foundWords.length > 1 ? 's' : ''} found`, detail: `Found: ${foundWords.slice(0, 5).join(', ')}. Consider rephrasing these.`});
deductions += foundWords.length * 8;
triggeredWords.push(...foundWords.slice(0, 5));
} else {
checks.push({type: 'error', label: `${foundWords.length} spam trigger words found`, detail: `Found: ${foundWords.slice(0, 6).join(', ')}${foundWords.length > 6 ? ` and ${foundWords.length - 6} more` : ''}. Rewrite to use natural language.`});
deductions += Math.min(foundWords.length * 8, 45);
triggeredWords.push(...foundWords.slice(0, 6));
}
// Check subject line length
if (subject.length === 0) {
checks.push({type: 'error', label: 'Empty subject line', detail: 'A subject line is required. Missing subjects trigger spam filters.'});
deductions += 20;
} else if (subject.length < 10) {
checks.push({type: 'warning', label: 'Subject line too short', detail: 'Very short subjects may appear suspicious. Aim for 3050 characters.'});
deductions += 5;
} else if (subject.length > 70) {
checks.push({type: 'warning', label: 'Subject line too long', detail: `${subject.length} characters. Subject lines over 70 characters are truncated and may reduce opens.`});
deductions += 5;
} else {
checks.push({type: 'pass', label: 'Good subject line length', detail: `${subject.length} characters — within the recommended 3070 character range.`});
}
// Check for excessive capitalization in subject
const subjectWords = subject.split(' ').filter(w => w.length > 2);
const capsWords = subjectWords.filter(w => w === w.toUpperCase() && /[A-Z]/.test(w));
if (capsWords.length > 2) {
checks.push({type: 'error', label: 'Excessive capitalization in subject', detail: `"${capsWords.join('", "')}" — all-caps words look like shouting and trigger spam filters.`});
deductions += 15;
} else if (capsWords.length > 0) {
checks.push({type: 'warning', label: 'Capitalized words in subject', detail: `"${capsWords.join('", "')}" — use sentence case for a more professional appearance.`});
deductions += 5;
} else {
checks.push({type: 'pass', label: 'No excessive capitalization', detail: 'Subject line uses normal capitalization.'});
}
// Check for excessive exclamation marks in subject
const exclamationCount = (subject.match(/!/g) || []).length;
if (exclamationCount > 1) {
checks.push({type: 'error', label: 'Multiple exclamation marks in subject', detail: `${exclamationCount} exclamation marks found. Multiple "!" are a strong spam signal.`});
deductions += 10;
} else if (exclamationCount === 1) {
checks.push({type: 'warning', label: 'Exclamation mark in subject', detail: 'A single exclamation mark is borderline. Prefer natural punctuation.'});
deductions += 3;
} else {
checks.push({type: 'pass', label: 'No excessive punctuation', detail: 'No exclamation marks in the subject line.'});
}
// Check for $ in subject
if (subject.includes('$')) {
checks.push({type: 'warning', label: 'Dollar sign in subject', detail: 'The $ symbol in subject lines is a common spam trigger. Spell out "dollars" or reframe the message.'});
deductions += 8;
}
// Check for % in subject (common in discount spam)
if (subject.includes('%')) {
checks.push({type: 'warning', label: 'Percentage in subject', detail: 'Percentage symbols in subject lines are frequently associated with promotional spam.'});
deductions += 5;
}
// Check body length
if (body.length > 0 && body.length < 50) {
checks.push({type: 'warning', label: 'Very short email body', detail: 'Very short bodies with mostly links raise spam flags. Add more context and value.'});
deductions += 5;
} else if (body.length >= 50) {
checks.push({type: 'pass', label: 'Email body length acceptable', detail: 'Body content length looks reasonable for a legitimate email.'});
}
const score = Math.max(0, 100 - deductions);
return {score, checks, triggeredWords};
}
function ScoreBadge({score}: {score: number}) {
const color = score >= 80 ? 'text-green-700 bg-green-50 border-green-200' : score >= 60 ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-red-700 bg-red-50 border-red-200';
const label = score >= 80 ? 'Likely Clean' : score >= 60 ? 'Risky' : 'High Spam Risk';
return (
<div className={`inline-flex flex-col items-center rounded-2xl border-2 px-8 py-6 ${color}`}>
<span className={'text-5xl font-extrabold'}>{score}</span>
<span className={'mt-1 text-sm font-semibold uppercase tracking-widest'}>{label}</span>
</div>
);
}
const faqs: FAQ[] = [
{
question: 'What is a spam checker?',
answer:
'A spam checker analyzes email subject lines and content for characteristics that spam filters use to block or flag emails. It looks for spam trigger words, excessive capitalization, suspicious punctuation, and other signals that inbox providers associate with spam. Running your email through a spam checker before sending helps improve deliverability.',
},
{
question: 'What causes emails to go to spam?',
answer:
'Emails land in spam for many reasons: spam trigger words in the subject or body, poor sender reputation, missing SPF/DKIM/DMARC authentication, sending to purchased or unengaged lists with high bounce rates, excessive links or images, HTML-to-text ratio issues, or being reported as spam by previous recipients. Authentication issues and sender reputation are the most common causes.',
},
{
question: 'How do I check if my email will be spam?',
answer:
'Use a spam checker tool like this one to analyze your subject line and content. Also check your email authentication setup—verify that SPF, DKIM, and DMARC are correctly configured for your domain. Send test emails to spam testing services, and monitor your deliverability metrics (bounce rate, spam complaint rate) over time.',
},
{
question: 'What are spam trigger words?',
answer:
'Spam trigger words are phrases commonly associated with spam emails. They include words like "free", "guaranteed", "act now", "click here", "make money", "winner", "prize", "urgent", and many others. Spam filters assign point values to these words, and emails exceeding a threshold score are flagged or blocked. Avoiding these words and using natural language improves deliverability.',
},
{
question: 'Is email authentication important for avoiding spam?',
answer:
'Yes—email authentication (SPF, DKIM, DMARC) is critical. Without proper authentication, receiving mail servers have no way to verify your email is legitimate, making it more likely to be treated as spam. Since 2024, Gmail and Yahoo require DMARC authentication for bulk senders. Properly authenticated emails with a clean sender reputation reach the inbox far more reliably than unauthenticated emails.',
},
];
export default function SpamCheckerPage() {
const [subject, setSubject] = useState('');
const [bodyText, setBodyText] = useState('');
const [result, setResult] = useState<SpamResult | null>(null);
const handleCheck = (e: React.FormEvent) => {
e.preventDefault();
setResult(analyzeSpam(subject, bodyText));
};
return (
<>
<NextSeo
title="Email Spam Checker | Free Subject Line & Content Spam Test | Plunk"
description="Free spam checker tool. Test your email subject line and content for spam trigger words, capitalization issues, and other factors that cause emails to land in spam."
canonical="https://www.useplunk.com/tools/spam-checker"
openGraph={{
title: 'Email Spam Checker | Free Subject Line & Content Spam Test | Plunk',
description: 'Free spam checker. Test your email subject line and content for factors that cause emails to land in spam folders.',
url: 'https://www.useplunk.com/tools/spam-checker',
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Spam+Checker&tag=Tool', alt: 'Plunk Spam Checker', width: 1200, height: 630}],
}}
/>
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
}
>
<span className={'font-medium text-neutral-900'}>§ T-03 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Email spam
<br />
checker
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Test your subject line and email content for spam trigger words, punctuation issues, and other
factors that cause emails to land in spam folders.
</p>
</motion.div>
</div>
</section>
{/* ========== CHECKER TOOL ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-2xl'}
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'border-b border-neutral-200 px-8 py-5'}>
<div className={'flex items-center gap-3'}>
<ShieldAlert className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Analyze email content
</span>
</div>
</div>
<form onSubmit={handleCheck} className={'space-y-4 p-8'}>
<div>
<label htmlFor="subject" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Subject line <span className={'text-red-500'}>*</span>
</label>
<Input
id="subject"
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="Your email subject line"
required
className={'w-full'}
/>
</div>
<div>
<label htmlFor="body" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Email body text <span className={'text-neutral-400 text-xs font-normal'}>(optional)</span>
</label>
<textarea
id="body"
value={bodyText}
onChange={e => setBodyText(e.target.value)}
placeholder="Paste your email body text here (plain text, no HTML needed)..."
rows={6}
className={'w-full rounded-lg border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 resize-none'}
/>
</div>
<Button type="submit" className={'w-full gap-2'}>
<ShieldAlert className={'h-4 w-4'} />
Check for spam signals
</Button>
</form>
</div>
{result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6 space-y-4'}
>
{/* Score */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<div className={'flex flex-col items-center gap-4 text-center'}>
<ScoreBadge score={result.score} />
<p className={'max-w-sm text-sm text-neutral-600'}>
{result.score >= 80
? 'Your email looks clean. No major spam signals detected.'
: result.score >= 60
? 'Some spam signals detected. Review the issues below and consider rewriting flagged sections.'
: 'High spam risk. Address the issues below before sending to protect deliverability.'}
</p>
</div>
</div>
{/* Issues */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mb-6 text-lg font-bold text-neutral-900'}
>
Detailed analysis
</h3>
<ul className={'space-y-3'}>
{result.checks.map((check, i) => (
<li key={i} className={'flex items-start gap-3'}>
{check.type === 'pass' ? (
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
) : check.type === 'warning' ? (
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
) : (
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
)}
<div>
<p className={'text-sm font-medium text-neutral-900'}>{check.label}</p>
<p className={'mt-0.5 text-xs text-neutral-500'}>{check.detail}</p>
</div>
</li>
))}
</ul>
</div>
{result.triggeredWords.length > 0 && (
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
<h3 className={'mb-3 text-sm font-semibold text-red-900'}>Flagged phrases</h3>
<div className={'flex flex-wrap gap-2'}>
{result.triggeredWords.map((word, i) => (
<span key={i} className={'rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-800'}>
{word}
</span>
))}
</div>
<p className={'mt-3 text-xs text-red-700'}>
Replace these with natural, conversational language to improve deliverability.
</p>
</div>
)}
</motion.div>
)}
</motion.div>
</section>
{/* ========== WHY EMAILS GO TO SPAM ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'Spam factors'}
title={'Why emails go to spam.'}
subtitle={'Spam filters look at dozens of signals. Content is just one part of the picture.'}
/>
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{
title: 'Spam trigger words',
body: 'Words like "free", "guaranteed", "click here", and "act now" score heavily with spam filters. Even one or two can tip the scales.',
},
{
title: 'Missing authentication',
body: 'Without SPF, DKIM, and DMARC configured, receiving servers cannot verify your email is legitimate. Authentication failures are the leading cause of spam classification.',
},
{
title: 'Poor sender reputation',
body: 'Email providers track bounce rates, spam complaints, and engagement over time. A low sender reputation score means even clean emails land in spam.',
},
{
title: 'Excessive capitalization',
body: 'ALL CAPS SUBJECT LINES and words in all caps look like shouting and are heavily penalized by spam filters. Use sentence case.',
},
{
title: 'High bounce & complaint rates',
body: 'If many recipients report your emails as spam or addresses bounce, providers penalize future emails from your domain.',
},
{
title: 'Purchased email lists',
body: 'Bought lists have unverified addresses, old data, and spam traps. Sending to them destroys your sender reputation quickly.',
},
].map((item, i) => (
<motion.div
key={item.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={
'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'
}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Send emails that reach the inbox.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Plunk automatically handles SPF, DKIM, and DMARC authentication the most critical deliverability
factor. Start free, no credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
<Link
href="/guides/email-deliverability"
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
}
>
Deliverability guide
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
<FAQSection faqs={faqs} schemaId="faq-spam-checker" />
<Footer />
</>
);
}
@@ -58,14 +58,14 @@ export default function VerifyEmailPage() {
return (
<>
<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."
title="Email Validator | Free Email Address Verification Tool | Plunk"
description="Free email validator tool. Check email addresses for validity, typos, disposable domains, and MX record configuration. Verify any email instantly."
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.',
title: 'Email Validator | Free Email Address Verification Tool | Plunk',
description: 'Free email validator. Check email addresses for validity, typos, disposable domains, and MX records. Verify any email instantly.',
url: 'https://www.useplunk.com/tools/verify-email',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Address+Validator&tag=Tool', alt: 'Plunk Email Validator Tool', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ActiveCampaignComparison() {
description:
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
url: 'https://www.useplunk.com/vs/activecampaign',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
images: [{url: 'https://www.useplunk.com/api/og?title=ActiveCampaign+Alternative%3A+Open-Source+%26+Affordable&tag=Comparison', alt: 'Plunk vs ActiveCampaign', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function BentoComparison() {
description:
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
url: 'https://www.useplunk.com/vs/bento',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Bento+Alternative%3A+Developer-First+Email+Platform&tag=Comparison', alt: 'Plunk vs Bento', width: 1200, height: 630}],
}}
/>
+3 -3
View File
@@ -56,14 +56,14 @@ 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."
description="Done with Brevo's contact-based pricing? Plunk is a developer-first, open-source alternative — pay per email not per contact, self-hostable, no contact limits."
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.',
"Done with Brevo's contact-based pricing? Plunk is open-source, self-hostable, and charges per email not per contact.",
url: 'https://www.useplunk.com/vs/brevo',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Brevo+Alternative%3A+Developer-First+%26+Open+Source&tag=Comparison', alt: 'Plunk vs Brevo', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -64,7 +64,7 @@ export default function ConvertkitComparison() {
description:
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
url: 'https://www.useplunk.com/vs/convertkit',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
images: [{url: 'https://www.useplunk.com/api/og?title=ConvertKit+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs ConvertKit', width: 1200, height: 630}],
}}
/>
+3 -3
View File
@@ -52,14 +52,14 @@ 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."
description="Frustrated with Customer.io's complexity and cost? Plunk is an open-source alternative with simple workflow automation, transparent pay-as-you-go pricing, and no CRM overhead."
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.',
"Frustrated with Customer.io's complexity and cost? Plunk is open-source with simple workflow automation and transparent pay-as-you-go pricing.",
url: 'https://www.useplunk.com/vs/customerio',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Customer.io+Alternative%3A+Open-Source+%26+Developer-Focused&tag=Comparison', alt: 'Plunk vs Customer.io', width: 1200, height: 630}],
}}
/>
+5 -5
View File
@@ -91,15 +91,15 @@ export default function CompetitorsIndex() {
return (
<>
<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."
title="Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk"
description="Looking for a Resend, Mailgun, or SendGrid alternative? Plunk is open-source, self-hostable, and includes transactional emails, marketing campaigns, and workflow automation — at $0.001/email."
canonical="https://www.useplunk.com/vs"
openGraph={{
title: 'Plunk vs Email Competitors | Compare Email Platforms',
title: 'Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk',
description:
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
'Open-source alternative to Resend, Mailgun, and SendGrid. Transactional + marketing emails in one self-hostable platform at $0.001/email.',
url: 'https://www.useplunk.com/vs',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Best+Email+Platform+Alternatives&tag=Comparison', alt: 'Plunk vs Competitors', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function KlaviyoComparison() {
description:
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
url: 'https://www.useplunk.com/vs/klaviyo',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Klaviyo+Alternative%3A+Affordable+E-commerce+Email&tag=Comparison', alt: 'Plunk vs Klaviyo', width: 1200, height: 630}],
}}
/>
+3 -3
View File
@@ -56,14 +56,14 @@ 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."
description="Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing, no contact limits, and full code transparency."
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.',
'Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing and no contact limits.',
url: 'https://www.useplunk.com/vs/loops',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Loops+Alternative%3A+Open+Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs Loops', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -64,7 +64,7 @@ export default function MailchimpComparison() {
description:
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
url: 'https://www.useplunk.com/vs/mailchimp',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Mailchimp+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs Mailchimp', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -61,7 +61,7 @@ export default function MailerliteComparison() {
description:
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
url: 'https://www.useplunk.com/vs/mailerlite',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
images: [{url: 'https://www.useplunk.com/api/og?title=MailerLite+Alternative%3A+Open-Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs MailerLite', width: 1200, height: 630}],
}}
/>
+3 -3
View File
@@ -52,14 +52,14 @@ 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."
description="Switching from Mailgun? Plunk is an open-source alternative with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers, no lock-in."
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.',
'Switching from Mailgun? Plunk is open-source with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers.',
url: 'https://www.useplunk.com/vs/mailgun',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Mailgun+Alternative%3A+Open-Source+with+Marketing+%26+Automation&tag=Comparison', alt: 'Plunk vs Mailgun', width: 1200, height: 630}],
}}
/>
+5 -5
View File
@@ -52,15 +52,15 @@ export default function PostmarkComparison() {
return (
<>
<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."
title="Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk"
description="Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns, workflows, and open-source code."
canonical="https://www.useplunk.com/vs/postmark"
openGraph={{
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
title: 'Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk',
description:
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
'Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns and open-source code.',
url: 'https://www.useplunk.com/vs/postmark',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Postmark+Alternative%3A+Pay-As-You-Go+vs+Fixed+Pricing&tag=Comparison', alt: 'Plunk vs Postmark', width: 1200, height: 630}],
}}
/>
+5 -5
View File
@@ -51,15 +51,15 @@ export default function ResendComparison() {
return (
<>
<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."
title="Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk"
description="Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing campaigns and workflow automation included."
canonical="https://www.useplunk.com/vs/resend"
openGraph={{
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
title: 'Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk',
description:
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
'Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing and automation included.',
url: 'https://www.useplunk.com/vs/resend',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Resend+Alternative&tag=Comparison', alt: 'Plunk vs Resend', width: 1200, height: 630}],
}}
/>
+3 -3
View File
@@ -56,14 +56,14 @@ 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."
description="Tired of SendGrid's pricing tiers and complexity? Plunk is an open-source alternative with pay-as-you-go pricing at $0.001/email, a modern API, and no lock-in."
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.',
"Tired of SendGrid's pricing tiers and complexity? Plunk is open-source with pay-as-you-go pricing at $0.001/email and no lock-in.",
url: 'https://www.useplunk.com/vs/sendgrid',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
images: [{url: 'https://www.useplunk.com/api/og?title=SendGrid+Alternative%3A+Open+Source+%26+Pay-As-You-Go&tag=Comparison', alt: 'Plunk vs SendGrid', width: 1200, height: 630}],
}}
/>
+1 -1
View File
@@ -66,7 +66,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -241,7 +241,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
<div className="space-y-4">
{upcomingActivities.map((activity, index) => (
<div key={`${activity.id}-${index}`}>
<ActivityItem activity={activity} isUpcoming={true} />
<ActivityItem activity={activity} status="upcoming" />
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
</div>
))}
+3 -2
View File
@@ -110,7 +110,7 @@ function isEmailActivity(type: string): boolean {
interface ActivityItemProps {
activity: Activity;
isUpcoming?: boolean;
status?: 'upcoming' | 'completed';
}
interface ActivityConfig {
@@ -342,11 +342,12 @@ function getActivityConfig(activity: Activity): ActivityConfig {
}
}
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
export const ActivityItem = memo(function ActivityItem({activity, status = 'completed'}: ActivityItemProps) {
const [showPreviewModal, setShowPreviewModal] = useState(false);
const config = getActivityConfig(activity);
const Icon = config.icon;
const timestamp = new Date(activity.timestamp);
const isUpcoming = status === 'upcoming';
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
return (
+1 -3
View File
@@ -9,7 +9,6 @@ interface ApiKeyDisplayProps {
description?: string;
isSecret?: boolean;
onRegenerate?: () => Promise<void>;
showRegenerate?: boolean;
}
export function ApiKeyDisplay({
@@ -18,7 +17,6 @@ export function ApiKeyDisplay({
description,
isSecret = false,
onRegenerate,
showRegenerate = false,
}: ApiKeyDisplayProps) {
const [showKey, setShowKey] = useState(!isSecret);
const [copied, setCopied] = useState(false);
@@ -101,7 +99,7 @@ export function ApiKeyDisplay({
)}
</AnimatePresence>
</Button>
{showRegenerate && onRegenerate && (
{onRegenerate && (
<Button
type="button"
variant="outline"
+6 -6
View File
@@ -59,13 +59,13 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
interface BillingLimitsProps {
projectId: string;
hasSubscription: boolean;
tier: 'free' | 'paid';
billingEnabled: boolean;
}
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) {
export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsProps) {
const [isEditing, setIsEditing] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -132,7 +132,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
};
// Free tier projects can view their usage but can't edit limits
const canEditLimits = hasSubscription;
const canEditLimits = tier === 'paid';
// If billing is not enabled, don't show the component
if (!billingEnabled) {
@@ -160,7 +160,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
<CardHeader>
<CardTitle>Billing Limits</CardTitle>
<CardDescription>
{hasSubscription
{tier === 'paid'
? 'Set monthly limits for each email category. Limits reset on the 1st of each month.'
: 'Free tier projects have a total limit of 1,000 emails per month across all categories.'}
</CardDescription>
@@ -168,7 +168,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
<CardContent>
<div className="space-y-6">
{/* Free tier info banner */}
{!hasSubscription && limitsData && (
{tier !== 'paid' && limitsData && (
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
@@ -208,7 +208,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
{!isEditing && limitsData && (
<div className="space-y-4">
{/* For free tier, show total usage across all categories */}
{!hasSubscription ? (
{tier !== 'paid' ? (
<UsageDisplay
category="Total Emails (All Categories)"
usage={limitsData.workflows}
+33 -2
View File
@@ -14,9 +14,11 @@ import {
Activity,
BarChart3,
BookOpen,
Check,
Clock,
Copy,
FileText,
FolderOpen,
Layers,
LayoutDashboard,
Megaphone,
@@ -85,7 +87,7 @@ function ShortcutHint({shortcut}: {shortcut: [string, string]}) {
export function CommandPalette() {
const router = useRouter();
const {activeProject} = useActiveProject();
const {activeProject, availableProjects, setActiveProject} = useActiveProject();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
@@ -188,6 +190,13 @@ export function CommandPalette() {
? CREATE_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
: CREATE_ACTIONS;
const filteredProjects = availableProjects.filter(p => matches(p.name, debouncedQuery || query));
const switchProject = (project: (typeof availableProjects)[number]) => {
setActiveProject(project);
setOpen(false);
};
const navigate = (href: string, label: string) => {
void router.push(href);
addRecentPage({label, href});
@@ -215,7 +224,8 @@ export function CommandPalette() {
workflows.length > 0 ||
segments.length > 0 ||
filteredNavActions.length > 0 ||
filteredCreateActions.length > 0
filteredCreateActions.length > 0 ||
filteredProjects.length > 0
: true;
return (
@@ -329,6 +339,27 @@ export function CommandPalette() {
</>
)}
{availableProjects.length > 1 && filteredProjects.length > 0 && (
<>
<CommandGroup heading="Switch project">
{filteredProjects.map(project => (
<CommandItem
key={project.id}
value={`project-${project.id}-${project.name}`}
onSelect={() => switchProject(project)}
>
<FolderOpen className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{project.name}</span>
{project.id === activeProject?.id && (
<Check className="ml-auto h-4 w-4 text-neutral-400 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{filteredNavActions.length > 0 && (
<CommandGroup heading="Go to">
{filteredNavActions.map(action => {
@@ -76,7 +76,6 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
// Fetch contacts for preview using SWR
const {contacts} = useContacts({limit: 50});
// Update available variables when fields change
useEffect(() => {
if (availableFields.length > 0) {
setAvailableVariables(availableFields);
+3 -11
View File
@@ -11,14 +11,9 @@ interface EmailSettingsProps {
fromPlaceholder?: string;
fromNamePlaceholder?: string;
replyToPlaceholder?: string;
showFromNameHelpText?: boolean;
layout?: 'vertical' | 'grid';
}
/**
* Reusable email settings component for from, fromName, and replyTo fields
* Used in campaign and template forms
*/
export function EmailSettings({
from,
fromName,
@@ -29,7 +24,6 @@ export function EmailSettings({
fromPlaceholder = 'hello',
fromNamePlaceholder = 'Your Company',
replyToPlaceholder,
showFromNameHelpText = false,
layout = 'grid',
}: EmailSettingsProps) {
// Use from email's local part as the reply-to placeholder if not provided
@@ -63,11 +57,9 @@ export function EmailSettings({
onChange={e => onFromNameChange(e.target.value)}
placeholder={fromNamePlaceholder}
/>
{showFromNameHelpText && (
<p className="text-xs text-neutral-500 mt-1">
The sender name that appears in the recipient&apos;s inbox. Defaults to your project name if not set.
</p>
)}
<p className="text-xs text-neutral-500 mt-1">
The sender name that appears in the recipient&apos;s inbox. Defaults to your project name if not set.
</p>
</div>
</GridWrapper>
+109 -63
View File
@@ -8,6 +8,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
@@ -16,26 +17,32 @@ import {Check, ChevronsUpDown, GripVertical, Plus, Search, Trash2} from 'lucide-
import {memo, useCallback, useEffect, useMemo, useState} from 'react';
import {network} from '../lib/network';
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'equals', label: 'Equals'},
{value: 'notEquals', label: 'Not equals'},
{value: 'contains', label: 'Contains'},
{value: 'notContains', label: 'Does not contain'},
{value: 'greaterThan', label: 'Greater than'},
{value: 'lessThan', label: 'Less than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal to'},
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{value: 'notExists', label: 'Does not exist'},
{value: 'within', label: 'Younger than (time)'},
{value: 'olderThan', label: 'Older than (time)'},
const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [
{value: 'equals', label: 'Equals', description: 'Exact match'},
{value: 'notEquals', label: 'Not equals', description: 'Anything other than this value'},
{value: 'contains', label: 'Contains', description: 'Value includes this text'},
{value: 'notContains', label: 'Does not contain', description: 'Value does not include this text'},
{value: 'greaterThan', label: 'Greater than', description: 'Value is higher than'},
{value: 'lessThan', label: 'Less than', description: 'Value is lower than'},
{value: 'greaterThanOrEqual', label: 'Greater than or equal', description: 'Value is at least'},
{value: 'lessThanOrEqual', label: 'Less than or equal', description: 'Value is at most'},
{value: 'exists', label: 'Has a value', description: 'Field is set to anything'},
{value: 'notExists', label: 'Has no value', description: 'Field is empty or unset'},
{value: 'within', label: 'Less than X ago', description: 'Date is within the last X days/hours'},
{value: 'olderThan', label: 'More than X ago', description: 'Date is older than X days/hours'},
];
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'triggered', label: 'Ever occurred'},
{value: 'triggeredWithin', label: 'Occurred within'},
{value: 'triggeredOlderThan', label: 'Occurred over (time) ago'},
{value: 'notTriggered', label: 'Never occurred'},
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [
{value: 'triggered', label: 'Ever occurred', description: 'This event has happened at least once'},
{value: 'triggeredWithin', label: 'Occurred within', description: 'Happened at least once in the last X days/hours'},
{value: 'triggeredOlderThan', label: 'Occurred, but not recently', description: 'Has happened before, but not in the last X days/hours'},
{value: 'notTriggered', label: 'Never occurred', description: 'This event has never happened'},
{value: 'notTriggeredWithin', label: 'Not occurred within', description: 'Has not happened in the last X days/hours — includes contacts who never triggered this'},
];
const SEGMENT_OPERATORS: {value: SegmentFilterOperator; label: string; description: string}[] = [
{value: 'memberOfSegment', label: 'Is member of', description: 'Contact is currently in this segment'},
{value: 'notMemberOfSegment', label: 'Is not member of', description: 'Contact is not in this segment'},
];
const TIME_UNITS = [
@@ -55,25 +62,27 @@ const STANDARD_FIELDS = [
interface FieldOption {
value: string;
label: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email';
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity';
description?: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'event' | 'email' | 'segment';
category: 'Contact Fields' | 'Custom Data' | 'Events' | 'Email Activity' | 'Segments';
}
// Hook to fetch available fields and events
function useAvailableOptions() {
// Hook to fetch available fields, events, and segments
function useAvailableOptions(currentSegmentId?: string) {
const [fields, setFields] = useState<FieldOption[]>([...STANDARD_FIELDS]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchOptions = async () => {
try {
// Fetch contact fields with types
const fieldsData = await network.fetch<{
fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>;
}>('GET', '/contacts/fields');
// Fetch event names
const eventsData = await network.fetch<{eventNames: string[]}>('GET', '/events/names');
// Fetch contact fields with types, event names, and segments in parallel
const [fieldsData, eventsData, segmentsData] = await Promise.all([
network.fetch<{
fields: Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'}>;
}>('GET', '/contacts/fields'),
network.fetch<{eventNames: string[]}>('GET', '/events/names'),
network.fetch<Array<{id: string; name: string; memberCount: number}>>('GET', '/segments'),
]);
// Build field options from typed fields
const typedFields: FieldOption[] = (fieldsData.fields || []).map(f => {
@@ -113,7 +122,18 @@ function useAvailableOptions() {
}
});
setFields([...typedFields, ...eventOptions, ...emailOptions]);
// Build segment options, excluding the current segment to prevent self-reference
const segmentOptions: FieldOption[] = (segmentsData || [])
.filter((s: {id: string; name: string; memberCount: number}) => s.id !== currentSegmentId)
.map((s: {id: string; name: string; memberCount: number}) => ({
value: `segment.${s.id}`,
label: s.name,
description: `${s.memberCount.toLocaleString()} ${s.memberCount === 1 ? 'person' : 'people'}`,
type: 'segment' as const,
category: 'Segments' as const,
}));
setFields([...typedFields, ...eventOptions, ...emailOptions, ...segmentOptions]);
} catch (error) {
console.error('Failed to fetch available fields and events:', error);
} finally {
@@ -122,7 +142,7 @@ function useAvailableOptions() {
};
fetchOptions();
}, []);
}, [currentSegmentId]);
return {fields, loading};
}
@@ -154,6 +174,10 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
// Helper to get valid operators for a field type
const getOperatorsForType = useCallback((type: string, isEvent: boolean) => {
if (type === 'segment') {
return SEGMENT_OPERATORS;
}
if (isEvent) {
return EVENT_OPERATORS;
}
@@ -185,8 +209,8 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
);
}, []);
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator);
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered', 'memberOfSegment', 'notMemberOfSegment'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator);
// Get field type from available fields
const fieldOption = useMemo(
@@ -196,9 +220,14 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
const fieldType = fieldOption?.type || 'string';
const isEventOrEmailActivity = fieldType === 'event' || fieldType === 'email';
const isSegment = fieldType === 'segment';
// Get operators based on field type (memoized)
const operators = useMemo(() => {
if (isSegment) {
return SEGMENT_OPERATORS;
}
if (isEventOrEmailActivity) {
return EVENT_OPERATORS;
}
@@ -229,23 +258,35 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
return STANDARD_OPERATORS.filter(op =>
['equals', 'notEquals', 'contains', 'notContains', 'exists', 'notExists'].includes(op.value),
);
}, [fieldType, isEventOrEmailActivity]);
}, [fieldType, isEventOrEmailActivity, isSegment]);
const handleFieldChange = useCallback(
(value: string) => {
const selectedField = availableFields.find(f => f.value === value);
const newFieldType = selectedField?.type || 'string';
const isEvent = newFieldType === 'event' || newFieldType === 'email';
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered'].includes(
const isNewSegment = newFieldType === 'segment';
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered', 'notTriggeredWithin'].includes(
filter.operator,
);
const currentOperatorIsSegment = ['memberOfSegment', 'notMemberOfSegment'].includes(filter.operator);
// Determine default operator and value based on new field type
let newOperator = filter.operator;
let newValue: string | number | boolean | undefined = undefined;
let newUnit: 'days' | 'hours' | 'minutes' | undefined = undefined;
let newValue: string | number | boolean | undefined = filter.value;
let newUnit: 'days' | 'hours' | 'minutes' | undefined = filter.unit;
if (isEvent && !currentOperatorIsEvent) {
if (isNewSegment && !currentOperatorIsSegment) {
// Switching to segment field
newOperator = 'memberOfSegment';
newValue = undefined;
newUnit = undefined;
} else if (!isNewSegment && currentOperatorIsSegment) {
// Switching from segment to non-segment field
newOperator = isEvent ? 'triggered' : 'equals';
newValue = isEvent ? undefined : getDefaultValueForType(newFieldType);
newUnit = undefined;
} else if (isEvent && !currentOperatorIsEvent) {
// Switching to event field
newOperator = 'triggered';
newValue = undefined;
@@ -273,7 +314,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
newUnit = undefined;
// If the new operator doesn't support units but we had them, ensure value is appropriate
const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(
newOperator,
);
if (!newOperatorNeedsUnit) {
@@ -292,7 +333,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
setOpen(false);
setSearch('');
},
[availableFields, filter.operator, fieldType, onChange, getDefaultValueForType, getOperatorsForType],
[availableFields, filter.operator, filter.value, filter.unit, fieldType, onChange, getDefaultValueForType, getOperatorsForType],
);
// Get label for selected field
@@ -381,9 +422,11 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
{field.type}
</span>
</div>
{field.value !== field.label && (
{field.description ? (
<span className="text-xs text-neutral-500">{field.description}</span>
) : field.value !== field.label ? (
<span className="text-xs text-neutral-500">{field.value}</span>
)}
) : null}
</div>
</button>
))}
@@ -405,12 +448,13 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
const oldOperator = filter.operator;
// Check if we're switching between operators that need different value types
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
const noValueOperators = ['exists', 'notExists', 'triggered', 'notTriggered', 'memberOfSegment', 'notMemberOfSegment'];
const oldNeedsValue = !noValueOperators.includes(oldOperator);
const newNeedsValue = !noValueOperators.includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(
oldOperator,
);
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(
newOperator,
);
@@ -426,28 +470,29 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
updatedFilter.unit = undefined;
} else if (newNeedsUnit && !oldNeedsUnit) {
updatedFilter.unit = 'days';
// Set default numeric value if needed
if (typeof updatedFilter.value !== 'number') {
updatedFilter.value = 7;
}
updatedFilter.value = typeof updatedFilter.value === 'number' && updatedFilter.value > 0 ? updatedFilter.value : 7;
}
// If switching to an operator that needs a value but we don't have one, set default
if (newNeedsValue && !oldNeedsValue) {
// Skip when entering a unit-based operator — it already set the value above
if (newNeedsValue && !oldNeedsValue && !newNeedsUnit) {
updatedFilter.value = getDefaultValueForType(fieldType);
}
onChange(updatedFilter);
}}
>
<SelectTrigger className="text-sm">
<SelectTrigger className="text-sm h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
<SelectItemWithDescription
key={op.value}
value={op.value}
title={op.label}
description={op.description}
/>
))}
</SelectContent>
</Select>
@@ -466,14 +511,14 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
type="number"
value={filter.value as number}
onChange={e => onChange({...filter, value: parseInt(e.target.value) || 0})}
className="text-sm flex-1"
className="text-sm flex-1 bg-white"
min="1"
/>
<Select
value={filter.unit || 'days'}
onValueChange={(v: 'days' | 'hours' | 'minutes') => onChange({...filter, unit: v})}
>
<SelectTrigger className="text-sm w-[110px]">
<SelectTrigger className="text-sm h-9 w-[110px] bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -490,7 +535,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
value={String(filter.value ?? 'true')}
onValueChange={v => onChange({...filter, value: v === 'true'})}
>
<SelectTrigger className="text-sm">
<SelectTrigger className="text-sm h-9 bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -506,7 +551,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
const val = e.target.value;
onChange({...filter, value: val === '' ? 0 : parseFloat(val) || 0});
}}
className="text-sm"
className="text-sm bg-white"
placeholder="Enter number"
/>
) : fieldType === 'date' ? (
@@ -514,14 +559,14 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
type="date"
value={filter.value ? String(filter.value).split('T')[0] : ''}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
className="text-sm bg-white"
/>
) : (
<Input
type="text"
value={String(filter.value ?? '')}
onChange={e => onChange({...filter, value: e.target.value})}
className="text-sm"
className="text-sm bg-white"
placeholder="Enter value"
/>
)}
@@ -758,10 +803,11 @@ function FilterConditionComponent({condition, onChange, depth = 0, availableFiel
interface SegmentFilterBuilderProps {
condition: FilterCondition;
onChange: (condition: FilterCondition) => void;
currentSegmentId?: string;
}
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions();
export function SegmentFilterBuilder({condition, onChange, currentSegmentId}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions(currentSegmentId);
if (loading) {
return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
@@ -0,0 +1,613 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {Button, IconSpinner, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui';
import {AlertTriangle, Info, Plus, Trash2} from 'lucide-react';
import {useMemo, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {type EditStepDialogProps, getStepConfig, type StepWithTemplate, StepDialogShell, useStepUpdate} from './shared';
type FieldType = 'string' | 'number' | 'boolean' | 'date';
type ConditionMode = 'binary' | 'multi';
interface AvailableField {
field: string;
type: string;
category: string;
}
interface BranchInput {
id: string;
name: string;
operator: string;
value: string;
}
interface OperatorOption {
value: string;
label: string;
types: FieldType[];
}
const ALL_OPERATORS: OperatorOption[] = [
{value: 'equals', label: 'Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'notEquals', label: 'Not Equals', types: ['string', 'number', 'boolean', 'date']},
{value: 'contains', label: 'Contains', types: ['string']},
{value: 'notContains', label: 'Does not contain', types: ['string']},
{value: 'greaterThan', label: 'Greater than', types: ['number', 'date']},
{value: 'lessThan', label: 'Less than', types: ['number', 'date']},
{value: 'greaterThanOrEqual', label: 'Greater than or equal', types: ['number', 'date']},
{value: 'lessThanOrEqual', label: 'Less than or equal', types: ['number', 'date']},
{value: 'exists', label: 'Exists', types: ['string', 'number', 'boolean', 'date']},
{value: 'notExists', label: 'Does not exist', types: ['string', 'number', 'boolean', 'date']},
];
const NO_VALUE_OPERATORS = ['exists', 'notExists'];
function getOperatorsForType(fieldType: string): OperatorOption[] {
return ALL_OPERATORS.filter(op => op.types.includes(fieldType as FieldType));
}
function extractInitialField(rawField: unknown): string {
if (!rawField) return '';
if (typeof rawField === 'object' && rawField !== null && 'field' in rawField) {
return String((rawField as {field?: unknown}).field ?? '');
}
return String(rawField);
}
function extractInitialBranches(config: Record<string, unknown>): BranchInput[] {
if (config.mode === 'multi' && Array.isArray(config.branches)) {
return (config.branches as any[]).map(b => ({
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: ''}];
}
function parseConditionValue(value: string): string | number | boolean {
if (value === 'true') return true;
if (value === 'false') return false;
if (value !== '' && !isNaN(Number(value))) return Number(value);
return value;
}
function hasMultiBranchConnections(step: StepWithTemplate): boolean {
if (step.type !== 'CONDITION') return false;
const config = getStepConfig(step);
if (config.mode !== 'multi') return false;
const transitions = (step as any).outgoingTransitions ?? [];
return transitions.some((t: any) => {
const condition = t.condition;
if (condition && typeof condition === 'object' && 'branch' in condition) {
const branch = String(condition.branch);
return branch !== 'yes' && branch !== 'no';
}
return false;
});
}
function hasBinaryConnections(step: StepWithTemplate): boolean {
if (step.type !== 'CONDITION') return false;
const config = getStepConfig(step);
if (config.mode === 'multi') return false;
const transitions = (step as any).outgoingTransitions ?? [];
return transitions.some((t: any) => {
const condition = t.condition;
if (condition && typeof condition === 'object' && 'branch' in condition) {
const branch = String(condition.branch);
return branch === 'yes' || branch === 'no';
}
return false;
});
}
export function ConditionStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const [name, setName] = useState(step.name);
const [conditionMode, setConditionMode] = useState<ConditionMode>(config.mode === 'multi' ? 'multi' : 'binary');
const [conditionField, setConditionField] = useState(extractInitialField(config.field));
const [conditionOperator, setConditionOperator] = useState(String(config.operator ?? 'equals'));
const [conditionValue, setConditionValue] = useState(String(config.value ?? ''));
const [conditionBranches, setConditionBranches] = useState<BranchInput[]>(() => extractInitialBranches(config));
const {data: workflow} = useSWR<{triggerConfig: {eventName?: string} | null}>(
workflowId ? `/workflows/${workflowId}` : null,
);
const triggerEventName = workflow?.triggerConfig?.eventName;
const fieldsUrl = open
? triggerEventName
? `/workflows/fields?eventName=${encodeURIComponent(triggerEventName)}`
: '/workflows/fields'
: null;
const {data: fieldsData, isLoading: loadingFields} = useSWR<{fields: string[]; typedFields: AvailableField[]}>(
fieldsUrl,
{revalidateOnFocus: false},
);
const availableFields: AvailableField[] = useMemo(() => {
if (!fieldsData) return [];
return (
fieldsData.typedFields ??
fieldsData.fields.map(f => ({field: f, type: 'string', category: 'Unknown'}))
);
}, [fieldsData]);
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const blocksMultiToBinary = hasMultiBranchConnections(step);
const blocksBinaryToMulti = hasBinaryConnections(step);
const currentFieldType = availableFields.find(f => f.field === conditionField)?.type ?? 'string';
const validOperators = useMemo(() => getOperatorsForType(currentFieldType), [currentFieldType]);
const needsValue = !NO_VALUE_OPERATORS.includes(conditionOperator);
const handleModeChange = (newMode: ConditionMode) => {
if (conditionMode === 'multi' && newMode === 'binary' && blocksMultiToBinary) return;
if (conditionMode === 'binary' && newMode === 'multi' && blocksBinaryToMulti) return;
setConditionMode(newMode);
};
const handleConditionFieldChange = (newField: string) => {
const newFieldType = availableFields.find(f => f.field === newField)?.type ?? 'string';
const newValidOperators = getOperatorsForType(newFieldType);
setConditionField(newField);
if (!newValidOperators.some(op => op.value === conditionOperator)) {
setConditionOperator('equals');
}
if (newFieldType === 'boolean') {
setConditionValue('true');
} else if (currentFieldType === 'boolean' && newFieldType !== 'boolean') {
setConditionValue('');
}
};
const updateBranch = (id: string, patch: Partial<BranchInput>) => {
setConditionBranches(prev => prev.map(b => (b.id === id ? {...b, ...patch} : b)));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
let newConfig: Record<string, unknown>;
if (conditionMode === 'multi') {
const validBranches = conditionBranches.filter(b => b.name.trim());
if (validBranches.length === 0) {
toast.error('At least one branch with a name is required');
return;
}
if (!conditionField) {
toast.error('Please select a field');
return;
}
newConfig = {
mode: 'multi' as const,
field: conditionField,
branches: validBranches.map(b => ({
id: b.id,
name: b.name.trim(),
operator: b.operator,
value: parseConditionValue(b.value),
})),
};
} else {
newConfig = {
field: conditionField,
operator: conditionOperator,
value: parseConditionValue(conditionValue),
};
}
const ok = await update({name, config: newConfig});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
const initialMode: ConditionMode = config.mode === 'multi' ? 'multi' : 'binary';
const showWiringWarning =
conditionMode !== initialMode &&
!blocksMultiToBinary &&
!blocksBinaryToMulti &&
Array.isArray((step as any).outgoingTransitions) &&
((step as any).outgoingTransitions as unknown[]).length > 0;
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-6">
<div className="space-y-4">
<ConditionModeToggle
mode={conditionMode}
onChange={handleModeChange}
blocksMultiToBinary={blocksMultiToBinary}
blocksBinaryToMulti={blocksBinaryToMulti}
showWiringWarning={showWiringWarning}
/>
<ConditionFieldPicker
value={conditionField}
onChange={handleConditionFieldChange}
availableFields={availableFields}
loading={loadingFields}
/>
{conditionMode === 'binary' && (
<BinaryCondition
operator={conditionOperator}
value={conditionValue}
onOperatorChange={setConditionOperator}
onValueChange={setConditionValue}
validOperators={validOperators}
fieldType={currentFieldType}
needsValue={needsValue}
/>
)}
{conditionMode === 'multi' && (
<MultiBranchEditor
branches={conditionBranches}
validOperators={validOperators}
onUpdateBranch={updateBranch}
onRemoveBranch={id => setConditionBranches(prev => prev.filter(b => b.id !== id))}
onAddBranch={() =>
setConditionBranches(prev => [
...prev,
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
])
}
/>
)}
</div>
</div>
</StepDialogShell>
);
}
interface ConditionModeToggleProps {
mode: ConditionMode;
onChange: (mode: ConditionMode) => void;
blocksMultiToBinary: boolean;
blocksBinaryToMulti: boolean;
showWiringWarning: boolean;
}
function ConditionModeToggle({
mode,
onChange,
blocksMultiToBinary,
blocksBinaryToMulti,
showWiringWarning,
}: ConditionModeToggleProps) {
return (
<div>
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => onChange('binary')}
disabled={mode === 'multi' && blocksMultiToBinary}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
mode === 'binary'
? 'border-neutral-900 bg-neutral-900 text-white'
: mode === 'multi' && blocksMultiToBinary
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`}
>
Simple (If/Else)
</button>
<button
type="button"
onClick={() => onChange('multi')}
disabled={mode === 'binary' && blocksBinaryToMulti}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
mode === 'multi'
? 'border-neutral-900 bg-neutral-900 text-white'
: mode === 'binary' && blocksBinaryToMulti
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
}`}
>
Multi-branch (Switch)
</button>
</div>
<p className="text-xs text-neutral-500 mt-1.5">
{mode === 'binary'
? 'Evaluates a single condition with Yes/No paths'
: 'Match a field against multiple values, each routing to its own branch'}
</p>
{blocksMultiToBinary && (
<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>
)}
{blocksBinaryToMulti && (
<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>
)}
{showWiringWarning && (
<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>
);
}
interface ConditionFieldPickerProps {
value: string;
onChange: (value: string) => void;
availableFields: AvailableField[];
loading: boolean;
}
function ConditionFieldPicker({value, onChange, availableFields, loading}: ConditionFieldPickerProps) {
const grouped = useMemo(() => {
return availableFields.reduce<Record<string, AvailableField[]>>((acc, field) => {
if (!acc[field.category]) acc[field.category] = [];
acc[field.category]!.push(field);
return acc;
}, {});
}, [availableFields]);
return (
<div>
<Label htmlFor="editConditionField">Field to Check *</Label>
{loading ? (
<div className="flex items-center gap-2 px-3 py-2 border border-neutral-200 rounded-lg text-sm text-neutral-500 mt-1.5">
<IconSpinner size="sm" />
Loading fields...
</div>
) : availableFields.length > 0 ? (
<Select value={value} onValueChange={onChange} required>
<SelectTrigger id="editConditionField" className="mt-1.5">
<SelectValue placeholder="Select a field..." />
</SelectTrigger>
<SelectContent>
{Object.entries(grouped).map(([category, fields]) => (
<div key={category}>
<div className="px-2 py-1.5 text-xs font-semibold text-neutral-500">{category}</div>
{fields.map(field => (
<SelectItem key={field.field} value={field.field}>
<div className="flex items-center gap-2">
<span>{field.field.replace('contact.', '').replace('data.', '')}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-neutral-200 text-neutral-600 font-mono">
{field.type}
</span>
</div>
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
) : (
<Input
id="editConditionField"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
required
placeholder="e.g., contact.subscribed or contact.data.plan"
className="mt-1.5"
/>
)}
</div>
);
}
interface BinaryConditionProps {
operator: string;
value: string;
onOperatorChange: (value: string) => void;
onValueChange: (value: string) => void;
validOperators: OperatorOption[];
fieldType: string;
needsValue: boolean;
}
function BinaryCondition({
operator,
value,
onOperatorChange,
onValueChange,
validOperators,
fieldType,
needsValue,
}: BinaryConditionProps) {
return (
<>
<div>
<Label htmlFor="editConditionOperator">Operator</Label>
<Select value={operator} onValueChange={onOperatorChange}>
<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>
</div>
{needsValue && (
<div>
<Label htmlFor="editConditionValue">Value</Label>
<ConditionValueInput fieldType={fieldType} value={value} onChange={onValueChange} />
</div>
)}
</>
);
}
interface ConditionValueInputProps {
fieldType: string;
value: string;
onChange: (value: string) => void;
}
function ConditionValueInput({fieldType, value, onChange}: ConditionValueInputProps) {
if (fieldType === 'boolean') {
return (
<Select value={value || 'true'} onValueChange={onChange}>
<SelectTrigger id="editConditionValue" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectContent>
</Select>
);
}
const inputType = fieldType === 'number' ? 'number' : fieldType === 'date' ? 'datetime-local' : 'text';
const placeholder =
fieldType === 'number' ? 'e.g., 100' : fieldType === 'date' ? '' : 'e.g., premium, active';
return (
<Input
id="editConditionValue"
type={inputType}
value={value}
onChange={e => onChange(e.target.value)}
required
placeholder={placeholder}
className="mt-1.5"
/>
);
}
interface MultiBranchEditorProps {
branches: BranchInput[];
validOperators: OperatorOption[];
onUpdateBranch: (id: string, patch: Partial<BranchInput>) => void;
onRemoveBranch: (id: string) => void;
onAddBranch: () => void;
}
function MultiBranchEditor({branches, validOperators, onUpdateBranch, onRemoveBranch, onAddBranch}: MultiBranchEditorProps) {
return (
<div className="space-y-3">
<Label>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>
{branches.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>
{branches.length > 1 && (
<Button
type="button"
variant="destructiveGhost"
size="icon"
className="h-7 w-7"
onClick={() => onRemoveBranch(branch.id)}
>
<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 => onUpdateBranch(branch.id, {name: e.target.value})}
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 => onUpdateBranch(branch.id, {operator: val})}
>
<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>
{!NO_VALUE_OPERATORS.includes(branch.operator) && (
<div>
<Label className="text-xs">Value</Label>
<Input
type="text"
value={branch.value}
onChange={e => onUpdateBranch(branch.id, {value: e.target.value})}
placeholder="Value..."
className="mt-1 h-8 text-sm"
/>
</div>
)}
</div>
</div>
))}
{branches.length < 20 && (
<button
type="button"
onClick={onAddBranch}
className="flex items-center gap-1.5 text-sm text-neutral-700 hover:text-neutral-900 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>
);
}
@@ -0,0 +1,88 @@
import {Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui';
import {useState} from 'react';
import {toast} from 'sonner';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
type DelayUnit = 'minutes' | 'hours' | 'days';
const MAX_DELAY_BY_UNIT: Record<DelayUnit, number> = {
minutes: 525600,
hours: 8760,
days: 365,
};
function isDelayUnit(value: unknown): value is DelayUnit {
return value === 'minutes' || value === 'hours' || value === 'days';
}
export function DelayStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const [name, setName] = useState(step.name);
const [delayAmount, setDelayAmount] = useState(String(config.amount ?? '24'));
const [delayUnit, setDelayUnit] = useState<DelayUnit>(isDelayUnit(config.unit) ? config.unit : 'hours');
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const amount = parseInt(delayAmount, 10);
if (amount > MAX_DELAY_BY_UNIT[delayUnit]) {
toast.error(`Delay cannot exceed 365 days (${MAX_DELAY_BY_UNIT[delayUnit]} ${delayUnit})`);
return;
}
const ok = await update({
name,
config: {amount, unit: delayUnit},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="editDelayAmount">Amount</Label>
<Input
id="editDelayAmount"
type="number"
value={delayAmount}
onChange={e => setDelayAmount(e.target.value)}
required
min="1"
max={MAX_DELAY_BY_UNIT[delayUnit]}
className="mt-1.5"
/>
</div>
<div>
<Label htmlFor="editDelayUnit">Unit</Label>
<Select value={delayUnit} onValueChange={value => setDelayUnit(value as DelayUnit)}>
<SelectTrigger id="editDelayUnit" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</StepDialogShell>
);
}
@@ -0,0 +1,58 @@
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue} from '@plunk/ui';
import {useState} from 'react';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
export function ExitStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const [name, setName] = useState(step.name);
const [exitReason, setExitReason] = useState(String(config.reason ?? 'completed'));
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await update({
name,
config: {reason: exitReason},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div>
<Label htmlFor="editExitReason">Exit Reason (optional)</Label>
<Select value={exitReason} onValueChange={setExitReason}>
<SelectTrigger id="editExitReason" className="mt-1.5">
<SelectValue placeholder="Select exit reason..." />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription value="completed" title="Completed" description="Contact finished the workflow successfully" />
<SelectItemWithDescription value="unsubscribed" title="Unsubscribed" description="Contact unsubscribed from communications" />
<SelectItemWithDescription value="not_eligible" title="Not Eligible" description="Contact doesn't meet the required criteria" />
<SelectItemWithDescription value="opted_out" title="Opted Out" description="Contact opted out of this workflow" />
<SelectItemWithDescription value="goal_achieved" title="Goal Achieved" description="Workflow goal was met before completion" />
<SelectItemWithDescription value="duplicate" title="Duplicate" description="Contact was already in this workflow" />
<SelectItemWithDescription value="error" title="Error" description="A technical issue occurred" />
<SelectItemWithDescription value="other" title="Other" description="Custom or unlisted reason" />
</SelectContent>
</Select>
</div>
</StepDialogShell>
);
}
@@ -0,0 +1,113 @@
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
import {useState} from 'react';
import {toast} from 'sonner';
import {TemplateSearchPicker} from '../TemplateSearchPicker';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const recipient = config.recipient as {type?: string; customEmail?: string} | undefined;
const [name, setName] = useState(step.name);
const [templateId, setTemplateId] = useState(step.template?.id ?? '');
const [recipientType, setRecipientType] = useState<'CONTACT' | 'CUSTOM'>(
recipient?.type === 'CUSTOM' ? 'CUSTOM' : 'CONTACT',
);
const [customEmail, setCustomEmail] = useState(recipient?.customEmail ?? '');
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!templateId) {
toast.error('Please select a template');
return;
}
if (recipientType === 'CUSTOM') {
if (!customEmail.trim()) {
toast.error('Please enter a custom email address');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customEmail)) {
toast.error('Please enter a valid email address');
return;
}
}
const ok = await update({
name,
templateId,
config: {
templateId,
recipient: {
type: recipientType,
...(recipientType === 'CUSTOM' && {customEmail: customEmail.trim()}),
},
},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<div>
<Label htmlFor="editTemplate">Email Template</Label>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
</div>
<div>
<Label htmlFor="editRecipientType">Send To</Label>
<Select value={recipientType} onValueChange={value => setRecipientType(value as 'CONTACT' | 'CUSTOM')}>
<SelectTrigger id="editRecipientType" className="mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value="CONTACT"
title="Contact"
description="Send to the contact that triggered the workflow"
/>
<SelectItemWithDescription
value="CUSTOM"
title="Custom Email"
description="Send to a specific email address"
/>
</SelectContent>
</Select>
</div>
{recipientType === 'CUSTOM' && (
<div>
<Label htmlFor="editCustomEmail">Email Address</Label>
<Input
id="editCustomEmail"
type="email"
value={customEmail}
onChange={e => setCustomEmail(e.target.value)}
required
placeholder="e.g., admin@example.com"
className="mt-1.5"
/>
</div>
)}
</div>
</StepDialogShell>
);
}
@@ -0,0 +1,54 @@
import {useState} from 'react';
import {toast} from 'sonner';
import {KeyValueEditor} from '../KeyValueEditor';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const initialUpdates =
config.updates && typeof config.updates === 'object'
? (config.updates as Record<string, string | number | boolean>)
: null;
const [name, setName] = useState(step.name);
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
initialUpdates,
);
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
toast.error('At least one field to update is required');
return;
}
const ok = await update({
name,
config: {updates: contactUpdateData},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
</StepDialogShell>
);
}
@@ -0,0 +1,187 @@
import {Command, CommandGroup, CommandItem, CommandList, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
type TimeUnit = 'minutes' | 'hours' | 'days';
const SECONDS_PER_UNIT: Record<TimeUnit, number> = {
minutes: 60,
hours: 60 * 60,
days: 60 * 60 * 24,
};
const MAX_BY_UNIT: Record<TimeUnit, number> = {
minutes: 525600,
hours: 8760,
days: 365,
};
function isTimeUnit(value: unknown): value is TimeUnit {
return value === 'minutes' || value === 'hours' || value === 'days';
}
function deriveTimeoutAmountUnit(timeoutSeconds: number): {amount: string; unit: TimeUnit} {
if (timeoutSeconds === 0) return {amount: '0', unit: 'days'};
if (timeoutSeconds % SECONDS_PER_UNIT.days === 0) {
return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.days), unit: 'days'};
}
if (timeoutSeconds % SECONDS_PER_UNIT.hours === 0) {
return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.hours), unit: 'hours'};
}
if (timeoutSeconds % SECONDS_PER_UNIT.minutes === 0) {
return {amount: String(timeoutSeconds / SECONDS_PER_UNIT.minutes), unit: 'minutes'};
}
return {amount: String(Math.round(timeoutSeconds / SECONDS_PER_UNIT.hours)), unit: 'hours'};
}
export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const initialTimeout = deriveTimeoutAmountUnit(Number(config.timeout) || 86400);
const initialUnit: TimeUnit = isTimeUnit(initialTimeout.unit) ? initialTimeout.unit : 'days';
const [name, setName] = useState(step.name);
const [eventName, setEventName] = useState(String(config.eventName ?? ''));
const [eventPopoverOpen, setEventPopoverOpen] = useState(false);
const [timeoutAmount, setTimeoutAmount] = useState(initialTimeout.amount);
const [timeoutUnit, setTimeoutUnit] = useState<TimeUnit>(initialUnit);
const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
revalidateOnFocus: false,
});
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!eventName) {
toast.error('Event name is required');
return;
}
const amount = parseInt(timeoutAmount, 10);
if (amount > MAX_BY_UNIT[timeoutUnit]) {
toast.error(`Timeout cannot exceed 365 days (${MAX_BY_UNIT[timeoutUnit]} ${timeoutUnit})`);
return;
}
const timeoutSeconds = amount > 0 ? amount * SECONDS_PER_UNIT[timeoutUnit] : 0;
const ok = await update({
name,
config: {eventName, timeout: timeoutSeconds},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
const filteredEventNames = eventNamesData?.eventNames?.filter(
n => !eventName || n.toLowerCase().includes(eventName.toLowerCase()),
);
const showCustomEntry = eventName.trim() && !eventNamesData?.eventNames?.some(n => n === eventName.trim());
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<div>
<Label htmlFor="editEventName">Event Name</Label>
<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>
{filteredEventNames?.map(n => (
<CommandItem
key={n}
value={n}
onSelect={() => {
setEventName(n);
setEventPopoverOpen(false);
}}
>
{n}
</CommandItem>
))}
{showCustomEntry && (
<CommandItem
key="__custom__"
value={eventName.trim()}
onSelect={() => {
setEventName(eventName.trim());
setEventPopoverOpen(false);
}}
>
Use &ldquo;{eventName.trim()}&rdquo;
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</div>
<div>
<Label htmlFor="editEventTimeoutAmount">Timeout (optional)</Label>
<div className="flex gap-2 mt-1.5">
<Input
id="editEventTimeoutAmount"
type="number"
value={timeoutAmount}
onChange={e => setTimeoutAmount(e.target.value)}
placeholder="1"
min="0"
max={MAX_BY_UNIT[timeoutUnit]}
className="flex-1"
/>
<Select value={timeoutUnit} onValueChange={value => setTimeoutUnit(value as TimeUnit)}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">Minutes</SelectItem>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-neutral-500 mt-1.5">If not received, the workflow continues after this time</p>
</div>
</div>
</StepDialogShell>
);
}
@@ -0,0 +1,175 @@
import {Button, Collapsible, CollapsibleContent, CollapsibleTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui';
import {ChevronDown, Plus, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useState} from 'react';
import {toast} from 'sonner';
import {WIKI_URI} from '../../lib/constants';
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
interface HeaderEntry {
key: string;
value: string;
}
export function WebhookStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
const config = getStepConfig(step);
const initialHeaders: HeaderEntry[] =
config.headers && typeof config.headers === 'object'
? Object.entries(config.headers as Record<string, unknown>).map(([key, value]) => ({key, value: String(value)}))
: [];
const [name, setName] = useState(step.name);
const [webhookUrl, setWebhookUrl] = useState(String(config.url ?? ''));
const [webhookMethod, setWebhookMethod] = useState(String(config.method ?? 'POST'));
const [webhookHeaders, setWebhookHeaders] = useState<HeaderEntry[]>(initialHeaders);
const [showWebhookInfo, setShowWebhookInfo] = useState(false);
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!webhookUrl) {
toast.error('Webhook URL is required');
return;
}
const headers: Record<string, string> = {};
webhookHeaders.forEach(header => {
if (header.key.trim() && header.value.trim()) {
headers[header.key.trim()] = header.value.trim();
}
});
const ok = await update({
name,
config: {url: webhookUrl, method: webhookMethod, headers},
});
if (ok) {
onOpenChange(false);
onSuccess();
}
};
const updateHeader = (index: number, patch: Partial<HeaderEntry>) => {
setWebhookHeaders(prev => prev.map((h, i) => (i === index ? {...h, ...patch} : h)));
};
return (
<StepDialogShell
step={step}
open={open}
onOpenChange={onOpenChange}
name={name}
onNameChange={setName}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<div className="space-y-4">
<Collapsible open={showWebhookInfo} onOpenChange={setShowWebhookInfo}>
<div className="flex items-center justify-between">
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700">
<ChevronDown className={`h-3 w-3 transition-transform ${showWebhookInfo ? 'rotate-180' : ''}`} />
{showWebhookInfo ? 'Hide' : 'View'} request payload
</CollapsibleTrigger>
<Link
href={`${WIKI_URI}/guides/webhooks#webhook-payload`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-500 hover:text-neutral-700 underline underline-offset-2"
>
Webhook guide
</Link>
</div>
<CollapsibleContent className="mt-2">
<pre className="text-[10px] bg-neutral-50 p-2 rounded border border-neutral-200 overflow-x-auto">
{`{
"contact": { "email": "user@example.com", "subscribed": true, "data": { ... } },
"workflow": { "id": "wf_...", "name": "Welcome Series" },
"execution": { "id": "exec_...", "startedAt": "2025-01-19T..." },
"event": { ... }
}`}
</pre>
</CollapsibleContent>
</Collapsible>
<div>
<Label htmlFor="editWebhookUrl">URL</Label>
<Input
className="font-mono mt-1.5"
id="editWebhookUrl"
type="url"
value={webhookUrl}
onChange={e => setWebhookUrl(e.target.value)}
required
placeholder="https://api.example.com/webhook"
/>
</div>
<div>
<Label htmlFor="editWebhookMethod">Method</Label>
<Select value={webhookMethod} onValueChange={setWebhookMethod}>
<SelectTrigger id="editWebhookMethod" className="font-mono mt-1.5">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<Label>Headers</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setWebhookHeaders(prev => [...prev, {key: '', value: ''}])}
className="h-7 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Add
</Button>
</div>
<div className="space-y-2">
{webhookHeaders.map((header, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder="Name"
value={header.key}
onChange={e => updateHeader(index, {key: e.target.value})}
className="text-sm font-mono"
/>
<Input
placeholder="Value"
value={header.value}
onChange={e => updateHeader(index, {value: e.target.value})}
className="text-sm font-mono"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setWebhookHeaders(prev => prev.filter((_, i) => i !== index))}
className="h-9 w-9 p-0 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
</div>
</StepDialogShell>
);
}
@@ -0,0 +1,29 @@
import {ConditionStepDialog} from './ConditionStepDialog';
import {DelayStepDialog} from './DelayStepDialog';
import {ExitStepDialog} from './ExitStepDialog';
import {SendEmailStepDialog} from './SendEmailStepDialog';
import {UpdateContactStepDialog} from './UpdateContactStepDialog';
import {WaitForEventStepDialog} from './WaitForEventStepDialog';
import {WebhookStepDialog} from './WebhookStepDialog';
import {type EditStepDialogProps} from './shared';
export function EditStepDialog(props: EditStepDialogProps) {
switch (props.step.type) {
case 'SEND_EMAIL':
return <SendEmailStepDialog {...props} />;
case 'DELAY':
return <DelayStepDialog {...props} />;
case 'CONDITION':
return <ConditionStepDialog {...props} />;
case 'WAIT_FOR_EVENT':
return <WaitForEventStepDialog {...props} />;
case 'WEBHOOK':
return <WebhookStepDialog {...props} />;
case 'UPDATE_CONTACT':
return <UpdateContactStepDialog {...props} />;
case 'EXIT':
return <ExitStepDialog {...props} />;
default:
return null;
}
}
@@ -0,0 +1,137 @@
import {Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label} from '@plunk/ui';
import type {WorkflowStep} from '@plunk/db';
import {WorkflowSchemas} from '@plunk/shared';
import {useState} from 'react';
import {toast} from 'sonner';
import {network} from '../../lib/network';
export const STEP_TYPE_LABELS: Record<WorkflowStep['type'], string> = {
TRIGGER: 'Trigger',
SEND_EMAIL: 'Send Email',
DELAY: 'Delay',
WAIT_FOR_EVENT: 'Wait for Event',
CONDITION: 'Condition',
EXIT: 'Exit',
WEBHOOK: 'Webhook',
UPDATE_CONTACT: 'Update Contact',
};
export const STEP_TYPE_DESCRIPTIONS: Record<WorkflowStep['type'], string> = {
TRIGGER: 'Starts the workflow when a specific event is received.',
SEND_EMAIL: 'Sends an email to the contact using a template you choose.',
DELAY: 'Pauses the workflow for a set amount of time before continuing.',
WAIT_FOR_EVENT: 'Waits until the contact triggers a specific event, then continues.',
CONDITION: 'Splits the flow based on contact data — each path leads to different steps.',
EXIT: 'Ends the workflow for the contact.',
WEBHOOK: "Makes an HTTP request to an external URL with the contact's data.",
UPDATE_CONTACT: "Sets or updates fields on the contact's profile.",
};
export type StepWithTemplate = WorkflowStep & {
template?: {id: string; name: string} | null;
};
export interface EditStepDialogProps {
step: StepWithTemplate;
workflowId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
export interface UpdateStepInput {
name: string;
config: Record<string, unknown>;
templateId?: string;
}
export function useStepUpdate(workflowId: string, stepId: string) {
const [isSubmitting, setIsSubmitting] = useState(false);
const update = async (input: UpdateStepInput): Promise<boolean> => {
setIsSubmitting(true);
try {
await network.fetch<WorkflowStep, typeof WorkflowSchemas.updateStep>(
'PATCH',
`/workflows/${workflowId}/steps/${stepId}`,
input as Parameters<typeof network.fetch>[2],
);
toast.success('Step updated successfully');
return true;
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update step');
return false;
} finally {
setIsSubmitting(false);
}
};
return {update, isSubmitting};
}
export function getStepConfig(step: {config: unknown}): Record<string, unknown> {
return step.config && typeof step.config === 'object' && !Array.isArray(step.config)
? (step.config as Record<string, unknown>)
: {};
}
interface StepDialogShellProps {
step: StepWithTemplate;
open: boolean;
onOpenChange: (open: boolean) => void;
name: string;
onNameChange: (value: string) => void;
onSubmit: (e: React.FormEvent) => void;
isSubmitting: boolean;
children: React.ReactNode;
}
export function StepDialogShell({
step,
open,
onOpenChange,
name,
onNameChange,
onSubmit,
isSubmitting,
children,
}: StepDialogShellProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit {STEP_TYPE_LABELS[step.type] ?? step.type}</DialogTitle>
{STEP_TYPE_DESCRIPTIONS[step.type] && (
<p className="text-sm text-neutral-500 mt-1">{STEP_TYPE_DESCRIPTIONS[step.type]}</p>
)}
</DialogHeader>
<form onSubmit={onSubmit} className="space-y-5">
<div>
<Label htmlFor="editStepName">Step Name</Label>
<Input
id="editStepName"
type="text"
value={name}
onChange={e => onNameChange(e.target.value)}
required
placeholder="e.g., Send Welcome Email"
className="mt-1.5"
/>
</div>
{children}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,5 @@
import type {Project} from '@plunk/db';
import {createContext, type ReactNode, useContext, useEffect, useState} from 'react';
import {createContext, type ReactNode, use, useEffect, useState} from 'react';
import {useSWRConfig} from 'swr';
import {useProjects} from '../hooks/useProject';
@@ -74,7 +74,7 @@ export function ActiveProjectProvider({children}: {children: ReactNode}) {
}
export function useActiveProject() {
const context = useContext(ActiveProjectContext);
const context = use(ActiveProjectContext);
if (context === undefined) {
throw new Error('useActiveProject must be used within an ActiveProjectProvider');
}
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -21,7 +21,7 @@ function Document({locale}: {locale: string}) {
property="og:description"
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
/>
<meta property="og:image" content="/assets/card.png" />
<meta property="og:image" content="https://next-app.useplunk.com/api/og?title=Email%20Platform%20Dashboard" />
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
@@ -30,7 +30,7 @@ function Document({locale}: {locale: string}) {
property="twitter:description"
content="Manage your email campaigns, contacts, and analytics with Plunk - the open-source email platform."
/>
<meta property="twitter:image" content="/assets/card.png" />
<meta property="twitter:image" content="https://next-app.useplunk.com/api/og?title=Email%20Platform%20Dashboard" />
{/* Fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -41,9 +41,10 @@ function Document({locale}: {locale: string}) {
/>
{/* Favicon */}
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon/favicon-16x16.png" sizes="16x16" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="apple-mobile-web-app-title" content="Plunk" />
+119
View File
@@ -0,0 +1,119 @@
import {ImageResponse} from 'next/og';
import type {NextRequest} from 'next/server';
import {getBricolageFont} from '../../lib/og-font';
export const config = {runtime: 'edge'};
const PLUNK_P_PATH =
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
export default function handler(req: NextRequest) {
const {searchParams} = new URL(req.url);
const title = searchParams.get('title') || 'Email Platform Dashboard';
const description = searchParams.get('description') || '';
const fontData = getBricolageFont();
const titleLength = title.length;
const fontSize = titleLength < 35 ? 72 : titleLength < 55 ? 60 : titleLength < 75 ? 50 : 42;
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '64px 72px',
backgroundColor: '#ffffff',
fontFamily: '"Bricolage Grotesque"',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Ghost P watermark */}
<div
style={{
position: 'absolute',
bottom: '-60px',
right: '-30px',
opacity: 0.035,
display: 'flex',
}}
>
<svg width="480" height="480" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#000000" />
</svg>
</div>
{/* Top row: logo + URL */}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '10px'}}>
<svg width="30" height="30" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#171717" />
</svg>
<span
style={{
fontSize: '22px',
fontWeight: 800,
color: '#171717',
letterSpacing: '-0.03em',
}}
>
Plunk
</span>
</div>
<span style={{fontSize: '15px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
next-app.useplunk.com
</span>
</div>
{/* Main content */}
<div style={{display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '920px'}}>
<div
style={{
fontSize: `${fontSize}px`,
fontWeight: 800,
color: '#0a0a0a',
lineHeight: 1.08,
letterSpacing: '-0.04em',
}}
>
{title}
</div>
{description ? (
<div
style={{
fontSize: '22px',
color: '#737373',
lineHeight: 1.5,
fontWeight: 400,
letterSpacing: '-0.01em',
}}
>
{description}
</div>
) : null}
</div>
{/* Bottom spacer */}
<div style={{display: 'flex'}} />
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Bricolage Grotesque',
data: fontData,
style: 'normal',
weight: 800,
},
],
},
);
}
+36 -29
View File
@@ -106,14 +106,18 @@ export default function CampaignDetailsPage() {
);
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false);
const [scheduledDateTime, setScheduledDateTime] = useState('');
const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false);
const [testEmailAddress, setTestEmailAddress] = useState('');
const [sendingTestEmail, setSendingTestEmail] = useState(false);
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [showSendDialog, setShowSendDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
type CampaignDialog =
| {type: 'none'}
| {type: 'schedule'}
| {type: 'testEmail'; sending: boolean}
| {type: 'send'}
| {type: 'cancel'}
| {type: 'delete'};
const [dialog, setDialog] = useState<CampaignDialog>({type: 'none'});
// Automatically initialize edit fields when campaign is loaded and is a draft
const isEditMode = campaign?.data.status === CampaignStatus.DRAFT;
@@ -172,7 +176,7 @@ export default function CampaignDetailsPage() {
// Show confirmation with user's local time
const localTimeString = formatFullDateTime(scheduledDate);
toast.success(`Campaign scheduled for ${localTimeString}`);
setIsScheduleDialogOpen(false);
setDialog({type: 'none'});
setScheduledDateTime('');
void mutate();
} catch (error) {
@@ -186,7 +190,7 @@ export default function CampaignDetailsPage() {
return;
}
setSendingTestEmail(true);
setDialog({type: 'testEmail', sending: true});
try {
await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, {
@@ -194,12 +198,12 @@ export default function CampaignDetailsPage() {
} as any);
toast.success(`Test email sent to ${testEmailAddress}`);
setIsTestEmailDialogOpen(false);
setDialog({type: 'none'});
setTestEmailAddress('');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send test email');
} finally {
setSendingTestEmail(false);
setDialog(d => (d.type === 'testEmail' ? {type: 'testEmail', sending: false} : d));
}
};
@@ -370,7 +374,7 @@ export default function CampaignDetailsPage() {
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
onClick={() => setDialog({type: 'delete'})}
className="flex-1 sm:flex-none"
>
<Trash2 className="h-4 w-4" />
@@ -395,7 +399,7 @@ export default function CampaignDetailsPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuItem onClick={() => setIsTestEmailDialogOpen(true)} className="py-3 cursor-pointer">
<DropdownMenuItem onClick={() => setDialog({type: 'testEmail', sending: false})} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<TestTube className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
@@ -406,7 +410,7 @@ export default function CampaignDetailsPage() {
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShowSendDialog(true)} className="py-3 cursor-pointer">
<DropdownMenuItem onClick={() => setDialog({type: 'send'})} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Send className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
@@ -417,7 +421,7 @@ export default function CampaignDetailsPage() {
</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsScheduleDialogOpen(true)} className="py-3 cursor-pointer">
<DropdownMenuItem onClick={() => setDialog({type: 'schedule'})} className="py-3 cursor-pointer">
<div className="flex items-start gap-3">
<Calendar className="h-4 w-4 mt-0.5 text-neutral-700" />
<div className="flex flex-col gap-0.5 flex-1">
@@ -531,7 +535,6 @@ export default function CampaignDetailsPage() {
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
showFromNameHelpText
layout="vertical"
/>
</CardContent>
@@ -658,7 +661,7 @@ export default function CampaignDetailsPage() {
</Card>
{/* Test Email Dialog */}
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
@@ -695,21 +698,25 @@ export default function CampaignDetailsPage() {
type="button"
variant="outline"
onClick={() => {
setIsTestEmailDialogOpen(false);
setDialog({type: 'none'});
setTestEmailAddress('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
<Button
type="button"
onClick={handleSendTestEmail}
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
>
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Schedule Dialog */}
<Dialog open={isScheduleDialogOpen} onOpenChange={setIsScheduleDialogOpen}>
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Schedule Campaign</DialogTitle>
@@ -801,7 +808,7 @@ export default function CampaignDetailsPage() {
type="button"
variant="outline"
onClick={() => {
setIsScheduleDialogOpen(false);
setDialog({type: 'none'});
setScheduledDateTime('');
}}
>
@@ -816,11 +823,11 @@ export default function CampaignDetailsPage() {
</form>
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
<ConfirmDialog
open={showSendDialog}
onOpenChange={setShowSendDialog}
open={dialog.type === 'send'}
onOpenChange={open => !open && setDialog({type: 'none'})}
onConfirm={handleSend}
title="Send Campaign"
description="Are you sure you want to send this campaign now? This action cannot be undone."
@@ -829,8 +836,8 @@ export default function CampaignDetailsPage() {
/>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
open={dialog.type === 'delete'}
onOpenChange={open => !open && setDialog({type: 'none'})}
onConfirm={handleDelete}
title="Delete Campaign"
description="Are you sure you want to delete this draft campaign? This action cannot be undone."
@@ -864,7 +871,7 @@ export default function CampaignDetailsPage() {
{/* Actions */}
{(c.status === CampaignStatus.SCHEDULED || c.status === CampaignStatus.SENDING) && (
<div className="flex justify-end">
<Button variant="destructive" onClick={() => setShowCancelDialog(true)} className="w-full sm:w-auto">
<Button variant="destructive" onClick={() => setDialog({type: 'cancel'})} className="w-full sm:w-auto">
<XCircle className="h-4 w-4" />
<span className="hidden sm:inline">Cancel Campaign</span>
<span className="sm:hidden">Cancel</span>
@@ -1069,8 +1076,8 @@ export default function CampaignDetailsPage() {
</div>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
open={dialog.type === 'cancel'}
onOpenChange={open => !open && setDialog({type: 'none'})}
onConfirm={handleCancel}
title="Cancel Campaign"
description="Are you sure you want to cancel this campaign?"
+1 -1
View File
@@ -297,7 +297,7 @@ export default function SegmentDetailPage() {
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader>
<CardContent>
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
<SegmentFilterBuilder condition={condition} onChange={setCondition} currentSegmentId={id as string} />
</CardContent>
</Card>
)}
+19 -19
View File
@@ -92,11 +92,11 @@ export default function Settings() {
const {data: user} = useUser();
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [resetConfirmText, setResetConfirmText] = useState('');
type SettingsDialog = {type: 'none'} | {type: 'regenerate'} | {type: 'delete'} | {type: 'reset'};
const [dialog, setDialog] = useState<SettingsDialog>({type: 'none'});
const [isLoadingBilling, setIsLoadingBilling] = useState(false);
const [selectedCurrency, setSelectedCurrency] = useState<string>('auto');
const [showCurrencySelector, setShowCurrencySelector] = useState(false);
@@ -235,18 +235,18 @@ export default function Settings() {
await projectsMutate();
setSuccessMessage('API keys regenerated successfully');
setShowRegenerateDialog(false);
setDialog({type: 'none'});
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to regenerate API keys');
setShowRegenerateDialog(false);
setDialog({type: 'none'});
}
};
const promptRegenerateKeys = () => {
setShowRegenerateDialog(true);
setDialog({type: 'regenerate'});
};
const handleStartSubscription = async (currency: string = 'auto') => {
@@ -314,7 +314,7 @@ export default function Settings() {
await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`);
setSuccessMessage('Project reset successfully. All data has been cleared.');
setShowResetDialog(false);
setDialog({type: 'none'});
setResetConfirmText('');
// Refresh the page to reload data
@@ -323,7 +323,7 @@ export default function Settings() {
}, 1500);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project');
setShowResetDialog(false);
setDialog({type: 'none'});
setResetConfirmText('');
}
};
@@ -338,7 +338,7 @@ export default function Settings() {
await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`);
setSuccessMessage('Project deleted successfully. Redirecting...');
setShowDeleteDialog(false);
setDialog({type: 'none'});
setDeleteConfirmText('');
// Refresh projects list and redirect to dashboard
@@ -350,7 +350,7 @@ export default function Settings() {
}, 1500);
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project');
setShowDeleteDialog(false);
setDialog({type: 'none'});
setDeleteConfirmText('');
}
};
@@ -587,7 +587,7 @@ export default function Settings() {
<span>API keys, domains, billing information</span>
</div>
</div>
<Button type="button" variant="outline" onClick={() => setShowResetDialog(true)} className="shrink-0">
<Button type="button" variant="outline" onClick={() => setDialog({type: 'reset'})} className="shrink-0">
Reset Data
</Button>
</div>
@@ -614,7 +614,7 @@ export default function Settings() {
<Button
type="button"
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
onClick={() => setDialog({type: 'delete'})}
className="shrink-0"
>
Delete Project
@@ -753,7 +753,7 @@ export default function Settings() {
{/* Billing Limits */}
<BillingLimits
projectId={activeProject.id}
hasSubscription={!!activeProject.subscription}
tier={activeProject.subscription ? 'paid' : 'free'}
billingEnabled={billingEnabled}
/>
@@ -814,7 +814,7 @@ export default function Settings() {
</div>
{/* Regenerate Keys Confirmation Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<Dialog open={dialog.type === 'regenerate'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
@@ -833,7 +833,7 @@ export default function Settings() {
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRegenerateKeys}>
@@ -844,7 +844,7 @@ export default function Settings() {
</Dialog>
{/* Reset Project Confirmation Dialog */}
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<Dialog open={dialog.type === 'reset'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="space-y-3">
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
@@ -876,7 +876,7 @@ export default function Settings() {
<Button
variant="outline"
onClick={() => {
setShowResetDialog(false);
setDialog({type: 'none'});
setResetConfirmText('');
}}
className="w-full"
@@ -896,7 +896,7 @@ export default function Settings() {
</Dialog>
{/* Delete Project Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<Dialog open={dialog.type === 'delete'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="space-y-3">
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
@@ -934,7 +934,7 @@ export default function Settings() {
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false);
setDialog({type: 'none'});
setDeleteConfirmText('');
}}
className="w-full"
+7 -15
View File
@@ -21,7 +21,7 @@ import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {useEffect, useMemo, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {TemplateSchemas, detectUnsubscribeSignal} from '@plunk/shared';
@@ -38,7 +38,6 @@ export default function TemplateEditorPage() {
const [editedTemplate, setEditedTemplate] = useState<Partial<Template>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize edit fields when template loads
@@ -54,16 +53,12 @@ export default function TemplateEditorPage() {
replyTo: template.replyTo || '',
type: template.type,
});
// Reset hasChanges when loading fresh data
setHasChanges(false);
}
}, [template, editedTemplate]);
// Track changes
useEffect(() => {
if (!template || Object.keys(editedTemplate).length === 0) return;
const changed =
const hasChanges = useMemo(() => {
if (!template || Object.keys(editedTemplate).length === 0) return false;
return (
editedTemplate.name !== template.name ||
(editedTemplate.description || '') !== (template.description || '') ||
editedTemplate.subject !== template.subject ||
@@ -71,9 +66,8 @@ export default function TemplateEditorPage() {
editedTemplate.from !== template.from ||
(editedTemplate.fromName || '') !== (template.fromName || '') ||
(editedTemplate.replyTo || '') !== (template.replyTo || '') ||
editedTemplate.type !== template.type;
setHasChanges(changed);
editedTemplate.type !== template.type
);
}, [editedTemplate, template]);
// Warn before leaving page with unsaved changes
@@ -96,7 +90,6 @@ export default function TemplateEditorPage() {
});
// Silent save - no toast notification
setHasChanges(false);
void mutate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save template');
@@ -267,7 +260,6 @@ export default function TemplateEditorPage() {
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
showFromNameHelpText
layout="vertical"
/>
</CardContent>
@@ -290,7 +282,7 @@ export default function TemplateEditorPage() {
</form>
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
{/* Delete Template Confirmation */}
<ConfirmDialog
File diff suppressed because it is too large Load Diff
+13
View File
@@ -48,8 +48,21 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]}
const page = source.getPage(params.slug);
if (!page) notFound();
const ogUrl = new URL('https://docs.useplunk.com/api/og');
ogUrl.searchParams.set('title', page.data.title);
if (page.data.description) {
ogUrl.searchParams.set('description', page.data.description);
}
return {
title: page.data.title,
description: page.data.description,
openGraph: {
images: [{url: ogUrl.toString(), width: 1200, height: 630}],
},
twitter: {
card: 'summary_large_image',
images: [ogUrl.toString()],
},
};
}
+135
View File
@@ -0,0 +1,135 @@
import {ImageResponse} from 'next/og';
import type {NextRequest} from 'next/server';
import {getBricolageFont} from '@/lib/og-font';
export const runtime = 'edge';
const PLUNK_P_PATH =
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
export function GET(req: NextRequest) {
const {searchParams} = new URL(req.url);
const title = searchParams.get('title') || 'Documentation';
const description = searchParams.get('description') || '';
const fontData = getBricolageFont();
const titleLength = title.length;
const fontSize = titleLength < 35 ? 72 : titleLength < 55 ? 60 : titleLength < 75 ? 50 : 42;
return new ImageResponse(
(
<div
style={{
width: '1200px',
height: '630px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '64px 72px',
backgroundColor: '#ffffff',
fontFamily: '"Bricolage Grotesque"',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Ghost P watermark */}
<div
style={{
position: 'absolute',
bottom: '-60px',
right: '-30px',
opacity: 0.035,
display: 'flex',
}}
>
<svg width="480" height="480" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#000000" />
</svg>
</div>
{/* Top row: logo + section tag */}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '10px'}}>
<svg width="30" height="30" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#171717" />
</svg>
<span
style={{
fontSize: '22px',
fontWeight: 800,
color: '#171717',
letterSpacing: '-0.03em',
}}
>
Plunk
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 14px',
backgroundColor: '#f4f4f5',
borderRadius: '6px',
fontSize: '15px',
color: '#52525b',
fontWeight: 600,
letterSpacing: '-0.01em',
}}
>
Documentation
</div>
</div>
{/* Main content */}
<div style={{display: 'flex', flexDirection: 'column', gap: '18px', maxWidth: '920px'}}>
<div
style={{
fontSize: `${fontSize}px`,
fontWeight: 800,
color: '#0a0a0a',
lineHeight: 1.08,
letterSpacing: '-0.04em',
}}
>
{title}
</div>
{description ? (
<div
style={{
fontSize: '22px',
color: '#737373',
lineHeight: 1.5,
fontWeight: 400,
letterSpacing: '-0.01em',
}}
>
{description}
</div>
) : null}
</div>
{/* Bottom: URL */}
<div style={{display: 'flex', justifyContent: 'flex-end'}}>
<span style={{fontSize: '15px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
docs.useplunk.com
</span>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Bricolage Grotesque',
data: fontData,
style: 'normal',
weight: 800,
},
],
},
);
}
+4 -3
View File
@@ -32,7 +32,7 @@ export default function Layout({children}: {children: ReactNode}) {
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://docs.useplunk.com/assets/card.png" />
<meta property="og:image" content="https://docs.useplunk.com/api/og?title=Documentation" />
{/* Twitter */}
<meta property="twitter:card" content="summary_large_image" />
@@ -42,7 +42,7 @@ export default function Layout({children}: {children: ReactNode}) {
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://docs.useplunk.com/assets/card.png" />
<meta property="twitter:image" content="https://docs.useplunk.com/api/og?title=Documentation" />
{/* Fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -54,9 +54,10 @@ export default function Layout({children}: {children: ReactNode}) {
/>
{/* Favicon */}
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon/favicon-16x16.png" sizes="16x16" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="apple-mobile-web-app-title" content="Plunk" />
+1 -2
View File
@@ -1,13 +1,12 @@
import {getLLMText} from '@/lib/get-llm-text';
import {source} from '@/lib/source';
import {notFound} from 'next/navigation';
export const revalidate = false;
export async function GET(_req: Request, props: {params: Promise<{slug?: string[]}>}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
if (!page) return new Response('Not found', {status: 404});
return new Response(await getLLMText(page), {
headers: {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -61,5 +61,5 @@ export function middleware(request: NextRequest) {
}
export const config = {
matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt)).*)',],
matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
};
+1 -1
View File
@@ -34,7 +34,7 @@
"@types/node": "^24.10.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

+20 -2
View File
@@ -61,12 +61,27 @@ export const AuthenticationSchemas = {
}),
} as const;
// Zero-width / bidi / formatting characters that render invisibly and aren't normalized away by NFKC.
const invisibleCharRegex =
/[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\u3164\uFEFF\uFFA0]/u;
const projectName = z
.string()
.min(1)
.max(100)
.refine(val => !invisibleCharRegex.test(val), {
message: 'Name contains invisible or formatting characters',
})
.refine(val => val.normalize('NFC') === val.normalize('NFKC'), {
message: 'Name contains decorative or look-alike characters. Use plain letters and numbers.',
});
export const ProjectSchemas = {
create: z.object({
name: z.string().min(1).max(100),
name: projectName,
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
name: projectName.optional(),
tracking: z.nativeEnum(TrackingMode).optional(),
language: z
.string()
@@ -108,6 +123,9 @@ const segmentFilterSchema = z.object({
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
'notTriggeredWithin',
'memberOfSegment',
'notMemberOfSegment',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
+5 -1
View File
@@ -23,7 +23,11 @@ export type SegmentFilterOperator =
| 'triggered' // Event/email activity occurred (any time)
| 'triggeredWithin' // Event/email activity occurred within timeframe
| 'triggeredOlderThan' // Event/email activity occurred more than X time ago
| 'notTriggered'; // Event/email activity never occurred
| 'notTriggered' // Event/email activity never occurred
| 'notTriggeredWithin' // Event/email activity has not occurred within timeframe (includes never-triggered)
// Segment membership operators
| 'memberOfSegment' // Contact is a member of another segment
| 'notMemberOfSegment'; // Contact is not a member of another segment
export type SegmentFilterLogic = 'AND' | 'OR';
+1 -1
View File
@@ -10,7 +10,7 @@
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^24.10.0",
"@types/react": "^19.2.7",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
},
+14 -16
View File
@@ -19,26 +19,24 @@ const alertVariants = cva(
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({className, variant, ...props}, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({variant}), className)} {...props} />
));
function Alert({
className,
variant,
ref,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return <div ref={ref} role="alert" className={cn(alertVariants({variant}), className)} {...props} />;
}
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({className, ...props}, ref) => (
<h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />
),
);
function AlertTitle({className, ref, ...props}: React.ComponentProps<'h5'>) {
return <h5 ref={ref} className={cn('mb-1 font-medium leading-none tracking-tight', className)} {...props} />;
}
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({className, ...props}, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
),
);
function AlertDescription({className, ref, ...props}: React.ComponentProps<'div'>) {
return <div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />;
}
AlertDescription.displayName = 'AlertDescription';
export {Alert, AlertTitle, AlertDescription};
+5 -9
View File
@@ -31,18 +31,14 @@ const buttonVariants = cva(
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
export interface ButtonProps extends React.ComponentProps<'button'>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({className, variant, size, asChild = false, ...props}, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({variant, size, className}))} ref={ref} {...props} />;
},
);
function Button({className, variant, size, asChild = false, ref, ...props}: ButtonProps) {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({variant, size, className}))} ref={ref} {...props} />;
}
Button.displayName = 'Button';
export {Button, buttonVariants};

Some files were not shown because too many files have changed in this diff Show More