Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 | ||
|
|
57bfbf874b | ||
|
|
1dc423a1c4 | ||
|
|
de8aaa5c45 | ||
|
|
1a1e3204ee | ||
|
|
6c4cc295b8 | ||
|
|
9f212ddcb8 | ||
|
|
5f82278282 | ||
|
|
f6a32ce610 | ||
|
|
0839975083 | ||
|
|
c90f3d0f01 | ||
|
|
5f3daf689b | ||
|
|
cc7fcdb4ef | ||
|
|
d75aed81e1 | ||
|
|
0cbaa8fc66 | ||
|
|
0686b3a3f2 | ||
|
|
9b15b93b96 | ||
|
|
8f2510f5ec | ||
|
|
e196d798c5 | ||
|
|
777a12ec89 | ||
|
|
6c6af775bc | ||
|
|
4f8c9f029d | ||
|
|
8f72994b46 | ||
|
|
d162f255c2 | ||
|
|
c8e252fefd | ||
|
|
b8f1ad9ab5 | ||
|
|
708ab506f1 | ||
|
|
ec1f4c9374 | ||
|
|
5528288c7b | ||
|
|
7bd098bdd0 | ||
|
|
cbea263a91 | ||
|
|
e8a247fe12 | ||
|
|
4b51e386e3 | ||
|
|
59aa7845ba | ||
|
|
5600c49bb6 | ||
|
|
21af8fe05e | ||
|
|
2c4d95e604 | ||
|
|
64bd094b47 | ||
|
|
53b33bbd36 | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
c40394ffd6 |
@@ -29,7 +29,7 @@ WIKI_URI=http://localhost:1000
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.6.0"
|
||||
".": "0.8.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||
|
||||
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
|
||||
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
|
||||
|
||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||
|
||||
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
@@ -135,7 +135,7 @@ between groups.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
|
||||
+2
-2
@@ -97,8 +97,8 @@ ARG TARGETPLATFORM
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://next.useplunk.com
|
||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailchecker": "^6.0.19",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
|
||||
@@ -72,12 +72,13 @@ export class Actions {
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
// Event tracking should subscribe contacts by default
|
||||
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||
// but preserve existing subscription state for existing contacts
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
email,
|
||||
data as Record<string, unknown> | undefined,
|
||||
subscribed ?? true,
|
||||
subscribed,
|
||||
);
|
||||
|
||||
// Track the event with ALL data (persistent + non-persistent)
|
||||
@@ -268,7 +269,9 @@ export class Actions {
|
||||
|
||||
// Create or update contact with metadata
|
||||
// Transactional emails should not subscribe contacts by default
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed ?? false);
|
||||
// New contacts default to unsubscribed unless explicitly opted in
|
||||
// Existing contacts preserve their subscription state unless explicitly changed
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
|
||||
|
||||
// Get merged data including non-persistent fields for template rendering
|
||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||
|
||||
@@ -72,20 +72,23 @@ export class Segments {
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
const {name, description, type, condition, trackMembership} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
||||
const segmentType = type ?? 'DYNAMIC';
|
||||
|
||||
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
@@ -142,6 +145,56 @@ export class Segments {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/members
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /segments/:id/members
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
@Delete(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/compute
|
||||
* Recompute segment membership for all contacts
|
||||
|
||||
@@ -6,6 +6,20 @@ import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import * as S3Service from '../services/S3Service.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
const MAGIC_BYTES: Record<string, Buffer[]> = {
|
||||
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
|
||||
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
|
||||
'image/webp': [Buffer.from('RIFF')],
|
||||
};
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
|
||||
const signatures = MAGIC_BYTES[mimetype];
|
||||
if (!signatures) return false;
|
||||
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
|
||||
}
|
||||
|
||||
// Configure multer for file uploads (memory storage)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
@@ -13,12 +27,12 @@ const upload = multer({
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
|
||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -48,6 +62,12 @@ export class Uploads {
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
|
||||
return res.status(400).json({
|
||||
error: 'File contents do not match the declared image type',
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file to S3/Minio
|
||||
const result = await S3Service.uploadFile({
|
||||
file: req.file.buffer,
|
||||
|
||||
@@ -34,11 +34,35 @@ export class Webhooks {
|
||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||
signale.info('SNS Subscription Confirmation received');
|
||||
signale.info('Subscribe URL:', req.body.SubscribeURL);
|
||||
|
||||
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
|
||||
// Legitimate URLs look like:
|
||||
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
|
||||
const subscribeURL: unknown = req.body.SubscribeURL;
|
||||
if (typeof subscribeURL !== 'string') {
|
||||
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(subscribeURL);
|
||||
} catch {
|
||||
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Automatically confirm the subscription
|
||||
try {
|
||||
const confirmResponse = await fetch(req.body.SubscribeURL);
|
||||
const confirmResponse = await fetch(subscribeURL);
|
||||
if (confirmResponse.ok) {
|
||||
signale.success('SNS subscription confirmed successfully');
|
||||
return res.status(200).json({
|
||||
@@ -50,7 +74,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Failed to confirm subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
} catch (confirmError) {
|
||||
@@ -58,7 +81,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Error confirming subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -760,6 +760,18 @@ export class CampaignService {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
return {
|
||||
...baseWhere,
|
||||
segmentMemberships: {
|
||||
some: {
|
||||
segmentId,
|
||||
exitedAt: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
import {isValidLanguageCode} from '@plunk/shared';
|
||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
|
||||
@@ -199,6 +198,7 @@ export class ContactService {
|
||||
email: string,
|
||||
data?: Record<string, unknown>,
|
||||
subscribed?: boolean,
|
||||
defaultSubscribed: boolean = true,
|
||||
): Promise<Contact> {
|
||||
// Find existing contact
|
||||
const existing = await prisma.contact.findFirst({
|
||||
@@ -235,12 +235,9 @@ export class ContactService {
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (typeof value === 'string') {
|
||||
if (!isValidLanguageCode(value)) {
|
||||
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
|
||||
}
|
||||
} else if (value !== null && value !== undefined) {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
@@ -292,7 +289,7 @@ export class ContactService {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? true,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,12 @@ import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||
import {
|
||||
deleteIdentity,
|
||||
disableFeedbackForwarding,
|
||||
getDomainVerificationAttributes,
|
||||
verifyDomain,
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
@@ -72,6 +77,43 @@ export class DomainService {
|
||||
|
||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (attributes.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(domain.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
signale.warn(
|
||||
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain if verification status changed
|
||||
if (attributes.status === 'Success' && !domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
@@ -84,6 +126,14 @@ export class DomainService {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable feedback forwarding for verified domain
|
||||
try {
|
||||
await disableFeedbackForwarding(domain.domain);
|
||||
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
|
||||
}
|
||||
|
||||
// Send notification about domain verified
|
||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ interface Attachment {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
interface SendEmailParams {
|
||||
@@ -381,7 +383,13 @@ export class EmailService {
|
||||
// Parse attachments from JSON
|
||||
const attachments =
|
||||
email.attachments && Array.isArray(email.attachments)
|
||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
||||
? (email.attachments as Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}>)
|
||||
: undefined;
|
||||
|
||||
// Determine tracking based on project settings and email type
|
||||
@@ -642,6 +650,378 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||
*/
|
||||
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
const classes = classValue.split(/\s+/).filter((c: string) => c.length > 0);
|
||||
const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle'];
|
||||
const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps visual editor content with a full HTML document and prose styles.
|
||||
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
||||
* match the preview modal exactly.
|
||||
*/
|
||||
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile HTML email with optional unsubscribe footer and badge
|
||||
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
||||
@@ -657,7 +1037,9 @@ export class EmailService {
|
||||
project: Project;
|
||||
includeUnsubscribe?: boolean;
|
||||
}): string {
|
||||
let html = content;
|
||||
// Wrap visual editor content with prose styles so the sent email matches the preview modal.
|
||||
// Custom HTML (from the HTML editor) already carries its own styles and is used as-is.
|
||||
let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content);
|
||||
|
||||
const unsubscribeHtml = includeUnsubscribe
|
||||
? (() => {
|
||||
@@ -711,7 +1093,7 @@ export class EmailService {
|
||||
<tr>
|
||||
<td style="width:180px;">
|
||||
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
||||
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
<img alt="Powered by Plunk" height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}[]
|
||||
| null;
|
||||
tracking?: boolean;
|
||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
||||
}
|
||||
|
||||
// Generate unique boundaries for multipart messages
|
||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
||||
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
|
||||
// Format To header with names if provided
|
||||
const toHeader = to
|
||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
||||
// Extract just email addresses for Destinations (SES requirement)
|
||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||
|
||||
// Determine root content type
|
||||
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
if (mixedBoundary) {
|
||||
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||
} else if (relatedBoundary) {
|
||||
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||
}
|
||||
|
||||
// Build raw MIME message
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
let rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${toHeader}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
${
|
||||
mixedBoundary
|
||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
}
|
||||
Content-Type: ${rootContentType}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
@@ -138,29 +149,61 @@ ${
|
||||
}
|
||||
${unsubscribeHeader}
|
||||
|
||||
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
|
||||
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
|
||||
}--${boundary}
|
||||
`;
|
||||
|
||||
// building the body
|
||||
if (mixedBoundary) {
|
||||
rawMessage += `--${mixedBoundary}\n`;
|
||||
if (relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
} else if (relatedBoundary) {
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
|
||||
// If we are nested, we need to specify that this next part is the alternative container
|
||||
if (mixedBoundary || relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||
}
|
||||
|
||||
// The alternative part content (always contains HTML)
|
||||
rawMessage += `--${altBoundary}
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${breakLongLines(content.html, 500)}
|
||||
--${boundary}--
|
||||
${
|
||||
attachments && attachments.length > 0
|
||||
? '\n' +
|
||||
attachments
|
||||
.map(
|
||||
attachment => `--${mixedBoundary}
|
||||
--${altBoundary}--
|
||||
`;
|
||||
|
||||
// Add inline attachments to the related container
|
||||
if (relatedBoundary) {
|
||||
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||
for (const attachment of inlineAttachments) {
|
||||
rawMessage += `\n--${relatedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||
Content-Disposition: inline; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${relatedBoundary}--`;
|
||||
}
|
||||
|
||||
// Add regular attachments to the mixed container
|
||||
if (mixedBoundary) {
|
||||
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||
for (const attachment of regularAttachments) {
|
||||
rawMessage += `\n--${mixedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`,
|
||||
)
|
||||
.join('\n')
|
||||
: ''
|
||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${mixedBoundary}--`;
|
||||
}
|
||||
|
||||
// Determine which configuration set to use
|
||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
@@ -66,11 +66,33 @@ export class SegmentService {
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedResponse<Contact>> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, query via SegmentMembership records
|
||||
const [memberships, total] = await Promise.all([
|
||||
prisma.segmentMembership.findMany({
|
||||
where: {segmentId, exitedAt: null},
|
||||
include: {contact: true},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {enteredAt: 'desc'},
|
||||
}),
|
||||
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: memberships.map(m => m.contact),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
where,
|
||||
@@ -98,23 +120,35 @@ export class SegmentService {
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
condition: FilterCondition;
|
||||
type?: SegmentType;
|
||||
condition?: FilterCondition;
|
||||
trackMembership?: boolean;
|
||||
},
|
||||
): Promise<Segment> {
|
||||
// Validate condition
|
||||
this.validateCondition(data.condition);
|
||||
const segmentType = data.type ?? 'DYNAMIC';
|
||||
let memberCount = 0;
|
||||
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
if (segmentType === 'DYNAMIC') {
|
||||
if (!data.condition) {
|
||||
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
|
||||
}
|
||||
// Validate condition
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
conditionJson = toPrismaJson(data.condition);
|
||||
}
|
||||
|
||||
const segment = await prisma.segment.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
condition: toPrismaJson(data.condition),
|
||||
type: segmentType,
|
||||
condition: conditionJson,
|
||||
trackMembership: data.trackMembership ?? false,
|
||||
memberCount,
|
||||
},
|
||||
@@ -145,12 +179,7 @@ export class SegmentService {
|
||||
},
|
||||
): Promise<Segment> {
|
||||
// First verify segment exists and belongs to project
|
||||
await this.get(projectId, segmentId);
|
||||
|
||||
// Validate condition if provided
|
||||
if (data.condition) {
|
||||
this.validateCondition(data.condition);
|
||||
}
|
||||
const existing = await this.get(projectId, segmentId);
|
||||
|
||||
const updateData: Prisma.SegmentUpdateInput = {};
|
||||
|
||||
@@ -160,7 +189,9 @@ export class SegmentService {
|
||||
if (data.description !== undefined) {
|
||||
updateData.description = data.description;
|
||||
}
|
||||
if (data.condition !== undefined) {
|
||||
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||
// Validate condition if provided (only for DYNAMIC segments)
|
||||
this.validateCondition(data.condition);
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
@@ -229,10 +260,16 @@ export class SegmentService {
|
||||
*/
|
||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segmentId},
|
||||
@@ -249,7 +286,7 @@ export class SegmentService {
|
||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, condition: true},
|
||||
select: {id: true, type: true, condition: true},
|
||||
});
|
||||
|
||||
// Process in batches to avoid overwhelming the database
|
||||
@@ -260,9 +297,17 @@ export class SegmentService {
|
||||
await Promise.all(
|
||||
batch.map(async segment => {
|
||||
try {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({
|
||||
where: {segmentId: segment.id, exitedAt: null},
|
||||
});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segment.id},
|
||||
@@ -276,6 +321,104 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
public static async addContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{added: number; notFound: string[]}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only add contacts to STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email (case-insensitive)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||
|
||||
if (contacts.length > 0) {
|
||||
// Check for existing memberships (to reactivate vs create new)
|
||||
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
|
||||
select: {contactId: true},
|
||||
});
|
||||
const existingIds = new Set(existingMemberships.map(m => m.contactId));
|
||||
|
||||
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
|
||||
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
|
||||
|
||||
if (newContactIds.length > 0) {
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reEntryIds.length > 0) {
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: reEntryIds}},
|
||||
data: {exitedAt: null, enteredAt: new Date()},
|
||||
});
|
||||
}
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {added: contacts.length, notFound};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
public static async removeContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{removed: number}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true},
|
||||
});
|
||||
|
||||
if (contacts.length > 0) {
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
|
||||
data: {exitedAt: new Date()},
|
||||
});
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {removed: contacts.length};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or recompute segment membership for all contacts
|
||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
||||
@@ -290,6 +433,13 @@ export class SegmentService {
|
||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||
}
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, just update the count from memberships — no contact scanning
|
||||
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
|
||||
return {added: 0, removed: 0, total};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Keys} from './keys.js';
|
||||
* Extract base domain from URL for cookie sharing across subdomains
|
||||
* e.g., "http://api.example.com" -> ".example.com"
|
||||
* e.g., "http://api.localhost" -> ".localhost"
|
||||
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||
*/
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (NODE_ENV === 'development') {
|
||||
@@ -28,10 +29,14 @@ function getCookieDomain(): string | undefined {
|
||||
// Extract base domain (last two parts for most domains, or .localhost)
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// For *.localhost or *.local, use the full hostname with leading dot
|
||||
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
||||
// For *.localhost, use .localhost (reserved TLD)
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
return '.localhost';
|
||||
}
|
||||
// For *.local (mDNS TLD), use the actual base domain
|
||||
if (hostname.endsWith('.local')) {
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
// For other domains, use the last two parts (e.g., .example.com)
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
|
||||
@@ -531,12 +531,9 @@ export class WorkflowExecutionService {
|
||||
throw new Error('No template configured for SEND_EMAIL step');
|
||||
}
|
||||
|
||||
// Parse step config to get recipient configuration
|
||||
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||
const recipientConfig =
|
||||
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||
: {type: 'CONTACT'};
|
||||
// Parse and validate step config using schema
|
||||
const stepConfig = WorkflowStepConfigSchemas.sendEmail.parse(config);
|
||||
const recipientConfig = stepConfig.recipient || {type: 'CONTACT' as const};
|
||||
|
||||
// Get contact data for variable substitution
|
||||
const contact = execution.contact;
|
||||
@@ -566,19 +563,13 @@ export class WorkflowExecutionService {
|
||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||
|
||||
// Determine recipient email
|
||||
let recipientEmail = contact.email;
|
||||
let recipientContactId = contact.id;
|
||||
|
||||
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||
recipientEmail = recipientConfig.customEmail;
|
||||
// For custom recipients, we don't associate with a contact
|
||||
recipientContactId = contact.id; // Keep original contact for tracking
|
||||
}
|
||||
// Schema validation ensures customEmail exists when type is CUSTOM
|
||||
const recipientEmail = recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail! : contact.email;
|
||||
|
||||
// Send email via EmailService
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId: execution.workflow.projectId,
|
||||
contactId: recipientContactId,
|
||||
contactId: contact.id, // Keep original contact for tracking
|
||||
workflowExecutionId: execution.id,
|
||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||
templateId: step.template.id,
|
||||
@@ -588,7 +579,7 @@ export class WorkflowExecutionService {
|
||||
fromName: step.template.fromName || undefined,
|
||||
replyTo: step.template.replyTo || undefined,
|
||||
// Pass custom recipient email if specified
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -727,7 +718,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
|
||||
|
||||
// Get the value to evaluate
|
||||
const contact = execution.contact;
|
||||
@@ -743,7 +734,7 @@ export class WorkflowExecutionService {
|
||||
// - data.firstName, data.lastName, etc.
|
||||
// - workflow.* (execution context - alias for event data)
|
||||
// - event.* (event data that triggered the workflow)
|
||||
const actualValue = this.resolveField(field, {
|
||||
const fieldData = {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -751,16 +742,47 @@ export class WorkflowExecutionService {
|
||||
data: contactData,
|
||||
workflow: context,
|
||||
event: context, // Alias for easier access to event data
|
||||
});
|
||||
};
|
||||
|
||||
// Evaluate the condition
|
||||
// Multi-branch mode (switch/case)
|
||||
if ('mode' in parsed && parsed.mode === 'multi') {
|
||||
const actualValue = this.resolveField(parsed.field, fieldData);
|
||||
|
||||
for (const branch of parsed.branches) {
|
||||
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: branch.name,
|
||||
actualValue,
|
||||
branch: branch.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No branch matched — use default
|
||||
return {
|
||||
field: parsed.field,
|
||||
mode: 'multi',
|
||||
matchedBranch: 'default',
|
||||
actualValue,
|
||||
branch: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy binary mode (if/else)
|
||||
const field = parsed.field;
|
||||
const operator = 'operator' in parsed ? parsed.operator : 'equals';
|
||||
const value = 'value' in parsed ? parsed.value : undefined;
|
||||
const actualValue = this.resolveField(field, fieldData);
|
||||
const result = this.evaluateCondition(actualValue, operator, value);
|
||||
|
||||
return {
|
||||
field,
|
||||
operator,
|
||||
expectedValue: value,
|
||||
actualValue,
|
||||
// Convert undefined to null so it's preserved in JSON (JSON.stringify removes undefined)
|
||||
actualValue: actualValue === undefined ? null : actualValue,
|
||||
result,
|
||||
branch: result ? 'yes' : 'no',
|
||||
};
|
||||
@@ -996,7 +1018,15 @@ export class WorkflowExecutionService {
|
||||
* Helper: Resolve field value from object using dot notation
|
||||
*/
|
||||
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
||||
const parts = field.split('.');
|
||||
// Handle legacy "contact.data.X" format by converting to "data.X"
|
||||
// The UI historically showed examples like "contact.data.plan" but the field structure
|
||||
// has "data" as a top-level key, not nested under "contact"
|
||||
let normalizedField = field;
|
||||
if (field.startsWith('contact.data.')) {
|
||||
normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X"
|
||||
}
|
||||
|
||||
const parts = normalizedField.split('.');
|
||||
let value: unknown = data;
|
||||
|
||||
for (const part of parts) {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import {EmailService} from '../EmailService';
|
||||
import {sendRawEmail} from '../SESService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock SES service
|
||||
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
|
||||
vi.mock('@aws-sdk/client-ses', () => {
|
||||
const SESMock = vi.fn();
|
||||
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
|
||||
return {SES: SESMock};
|
||||
});
|
||||
|
||||
// Mock constants to provide AWS credentials for SESService, preserving other exports
|
||||
vi.mock('../../app/constants.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../app/constants.js')>();
|
||||
return {
|
||||
...actual,
|
||||
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
|
||||
AWS_SES_REGION: 'us-east-1',
|
||||
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
|
||||
SES_CONFIGURATION_SET: 'test-config-set',
|
||||
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
|
||||
TRACKING_TOGGLE_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SES service (default behavior for most tests)
|
||||
vi.mock('../SESService', () => ({
|
||||
sendRawEmail: vi.fn(),
|
||||
}));
|
||||
@@ -801,5 +822,204 @@ describe('EmailService', () => {
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept inline attachment with contentId', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
from: 'test@example.com',
|
||||
subject: 'Inline Image',
|
||||
body: '<img src="cid:logo" />',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'logo.png',
|
||||
content: Buffer.from('image').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'logo',
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const attachment = result.data.attachments![0];
|
||||
expect(attachment.contentId).toBe('logo');
|
||||
expect(attachment.disposition).toBe('inline');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject contentId exceeding 255 chars', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'a'.repeat(256),
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid disposition', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: 'test@example.com',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
disposition: 'invalid-disposition',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SES MIME BOUNDARY STRUCTURE
|
||||
// ========================================
|
||||
// These tests verify the raw MIME assembly logic inside sendRawEmail.
|
||||
// They need the REAL sendRawEmail (not the mock above), so we mock
|
||||
// at the AWS SDK level instead.
|
||||
|
||||
describe('SES MIME Boundary Structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
expect(ses.sendRawEmail).toHaveBeenCalled();
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Mixed -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
|
||||
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Related -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
|
||||
|
||||
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly nest mixed > related > alternative boundaries', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: 'sender@example.com'},
|
||||
to: ['recipient@example.com'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Root should be mixed
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
|
||||
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
|
||||
|
||||
// Within mixed, we should find related
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
|
||||
|
||||
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
|
||||
|
||||
// Within related, we should find alternative
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
|
||||
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
|
||||
|
||||
// Verify all closing boundaries exist
|
||||
expect(rawMessage).toContain(`--${altBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
|
||||
expect(branch).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTI-BRANCH CONDITIONS (switch/case)
|
||||
// ========================================
|
||||
describe('Multi-Branch Conditions', () => {
|
||||
/**
|
||||
* Helper to create a workflow with a multi-branch condition step
|
||||
* Creates one exit step per branch + one for default
|
||||
*/
|
||||
async function createMultiBranchWorkflow(
|
||||
contactData: Record<string, unknown>,
|
||||
conditionConfig: Record<string, unknown>,
|
||||
) {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: contactData,
|
||||
});
|
||||
|
||||
const workflow = await factories.createWorkflow({projectId});
|
||||
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
|
||||
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||
});
|
||||
|
||||
const conditionStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Multi Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: conditionConfig,
|
||||
},
|
||||
});
|
||||
|
||||
// Create exit steps for each branch + default
|
||||
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
|
||||
const branchExits: Record<string, string> = {};
|
||||
for (let i = 0; i < branches.length; i++) {
|
||||
const branch = branches[i]!;
|
||||
const exitStep = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: `${branch.name} Path`,
|
||||
position: {x: 200, y: i * 100},
|
||||
config: {reason: branch.name},
|
||||
},
|
||||
});
|
||||
branchExits[branch.id] = exitStep.id;
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: branch.id},
|
||||
priority: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create default exit
|
||||
const defaultExit = await prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Default Path',
|
||||
position: {x: 200, y: branches.length * 100},
|
||||
config: {reason: 'default'},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: defaultExit.id,
|
||||
condition: {branch: 'default'},
|
||||
priority: branches.length,
|
||||
},
|
||||
});
|
||||
|
||||
// Connect trigger to condition
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
|
||||
});
|
||||
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId: workflow.id,
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
},
|
||||
});
|
||||
|
||||
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
|
||||
}
|
||||
|
||||
const planBranches = {
|
||||
mode: 'multi' as const,
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
|
||||
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
|
||||
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
|
||||
],
|
||||
};
|
||||
|
||||
it('should match the first matching branch', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-premium');
|
||||
});
|
||||
|
||||
it('should match a non-first branch correctly', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'enterprise'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-enterprise');
|
||||
});
|
||||
|
||||
it('should fall through to default when no branch matches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'starter'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should fall through to default when field is missing', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{other: 'value'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('default');
|
||||
});
|
||||
|
||||
it('should support mixed operators across branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{score: 85},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.score',
|
||||
branches: [
|
||||
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
|
||||
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
|
||||
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-medium');
|
||||
});
|
||||
|
||||
it('should respect branch evaluation order (first match wins)', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
|
||||
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
// Both match, but first one should win
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-first');
|
||||
});
|
||||
|
||||
it('should support exists/notExists operators in branches', async () => {
|
||||
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||
{plan: 'premium'},
|
||||
{
|
||||
mode: 'multi',
|
||||
field: 'data.plan',
|
||||
branches: [
|
||||
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||
|
||||
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||
expect(branch).toBe('br-has-plan');
|
||||
});
|
||||
|
||||
it('should route to correct exit step via transitions', async () => {
|
||||
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
|
||||
{plan: 'free'},
|
||||
planBranches,
|
||||
);
|
||||
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
|
||||
// Check the execution completed and ended at the correct exit step
|
||||
const completedExecution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: execution.id},
|
||||
});
|
||||
|
||||
// Workflow completed after reaching exit step
|
||||
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
|
||||
|
||||
// Verify it went through the "free" branch exit, not default
|
||||
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: branchExits['br-free'],
|
||||
},
|
||||
});
|
||||
expect(exitStepExecution).not.toBeNull();
|
||||
|
||||
// Verify it did NOT go through the default exit
|
||||
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||
where: {
|
||||
executionId: execution.id,
|
||||
stepId: defaultExitId,
|
||||
},
|
||||
});
|
||||
expect(defaultStepExecution).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -13,12 +13,12 @@ export default function Footer() {
|
||||
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
||||
<div className="grid gap-12 lg:grid-cols-12">
|
||||
{/* Logo and description */}
|
||||
<div className="space-y-6 lg:col-span-4">
|
||||
<div className="space-y-6 lg:col-span-3">
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-neutral-600">
|
||||
Open-source email automation platform that scales
|
||||
Open-source email platform for transactional, marketing, and automation. EU-hosted, GDPR compliant.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
@@ -57,7 +57,7 @@ export default function Footer() {
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="grid grid-cols-2 gap-8 lg:col-span-8 lg:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
@@ -67,20 +67,95 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
Made by humans
|
||||
<Link href={WIKI_URI} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Documentation
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
Documentation
|
||||
<Link href={'/guides'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Guides
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Tools
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Made by humans
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Features</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/features/email-editor'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email editor
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/workflows'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Workflows
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/segments'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Segments
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/smtp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
SMTP
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/features/inbound-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Inbound email
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Compare</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/vs'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
All comparisons
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/mailchimp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Mailchimp
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/sendgrid'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs SendGrid
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/resend'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Resend
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/brevo'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Brevo
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/mailgun'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs Mailgun
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/vs/convertkit'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
vs ConvertKit
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -95,28 +170,18 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={'https://github.com/useplunk'}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
<Link href={'https://github.com/useplunk'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
GitHub
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={'https://status.useplunk.com'}
|
||||
target={'_blank'}
|
||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
||||
>
|
||||
<Link href={'https://status.useplunk.com'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Status
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Legal</h3>
|
||||
<h3 className="mt-8 text-sm font-semibold text-neutral-900">Legal</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
@@ -135,6 +200,47 @@ export default function Footer() {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900">Guides</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/guides/email-deliverability'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email deliverability
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/transactional-vs-marketing-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Transactional vs marketing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-dkim'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is DKIM?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-spf'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is SPF?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/what-is-dmarc'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
What is DMARC?
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/email-open-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email open rates
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/guides/email-bounce-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
Email bounce rates
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,193 +4,305 @@ import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../../public/assets/logo.svg';
|
||||
import {ChevronDown, GitBranch, Inbox, Mail, Server, Users} from 'lucide-react';
|
||||
|
||||
const featuresMenu = [
|
||||
{
|
||||
title: 'Email Editor',
|
||||
description: 'Create beautiful emails with visual or code editing',
|
||||
href: '/features/email-editor',
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Workflows',
|
||||
description: 'Automate email sequences with triggers and conditions',
|
||||
href: '/features/workflows',
|
||||
icon: <GitBranch className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Inbound Email',
|
||||
description: 'Receive and process incoming emails',
|
||||
href: '/features/inbound-email',
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'Segments',
|
||||
description: 'Organize contacts with dynamic filtering',
|
||||
href: '/features/segments',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: 'SMTP',
|
||||
description: 'Send emails via SMTP or API',
|
||||
href: '/features/smtp',
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Navbar() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [featuresOpen, setFeaturesOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className={'relative top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
|
||||
<div className={'z-40 py-6'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
<header className={'sticky top-0 z-40 w-full border-b border-neutral-100 bg-white/95 backdrop-blur-sm'}>
|
||||
<div className={'relative mx-auto max-w-7xl px-8 xl:px-0'}>
|
||||
<div className={'py-5'}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-12">
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
||||
<div className={'relative h-8 w-8'}>
|
||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||
</div>
|
||||
<span className={'sr-only'}>Plunk</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
<div className={'relative'}>
|
||||
<button
|
||||
onMouseEnter={() => setFeaturesOpen(true)}
|
||||
onMouseLeave={() => setFeaturesOpen(false)}
|
||||
className={
|
||||
'flex items-center gap-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
Features
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${featuresOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{featuresOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: -10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -10}}
|
||||
transition={{duration: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
onMouseEnter={() => setFeaturesOpen(true)}
|
||||
onMouseLeave={() => setFeaturesOpen(false)}
|
||||
className={
|
||||
'absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg'
|
||||
}
|
||||
>
|
||||
{featuresMenu.map(feature => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
className={'flex items-start gap-3 rounded-lg p-3 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className={'sr-only'}>Plunk</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className={
|
||||
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
Docs
|
||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-8 md:flex">
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
Sign in
|
||||
</a>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Docs
|
||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
||||
Get started
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<div className="-mr-2 flex items-center md:hidden">
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
|
||||
<svg
|
||||
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
<svg
|
||||
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Get started
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<div className="-mr-2 flex items-center md:hidden">
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||
aria-controls="mobile-menu"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
|
||||
<svg
|
||||
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="absolute left-0 top-full z-50 mt-2 w-full rounded-lg border border-neutral-200 bg-white shadow-lg backdrop-blur-sm sm:hidden"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
initial={{height: 0, opacity: 0}}
|
||||
animate={{height: 'auto', opacity: 1}}
|
||||
exit={{height: 0, opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="space-y-1 p-4"
|
||||
className="absolute left-0 top-full z-50 w-full overflow-hidden border-t border-neutral-100 bg-white shadow-lg md:hidden"
|
||||
>
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{duration: 0.2}}
|
||||
className="space-y-1 p-4"
|
||||
>
|
||||
By humans
|
||||
</Link>
|
||||
<div className="mb-2">
|
||||
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Features
|
||||
</div>
|
||||
{featuresMenu.map(feature => (
|
||||
<Link
|
||||
key={feature.href}
|
||||
href={feature.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-start gap-3 rounded-lg px-4 py-3 transition hover:bg-neutral-100"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
<Link
|
||||
href={'/made-by-humans'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
|
||||
By humans
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Get started
|
||||
Pricing
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={'/guides'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Guides
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
rel={'noreferrer'}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-4">
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/login`}
|
||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
<a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
||||
|
||||
// Generate breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||
{position: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {toast, Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {network} from '../lib/network';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import Script from 'next/script';
|
||||
|
||||
/**
|
||||
* Main app component
|
||||
@@ -15,10 +16,6 @@ import {DefaultSeo} from 'next-seo';
|
||||
*/
|
||||
function App({Component, pageProps}: AppProps) {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const message = searchParams.get('message');
|
||||
|
||||
@@ -63,11 +60,18 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.driaug.com/script.js"
|
||||
data-website-id="6ed9fa6c-3a75-4926-ad4d-f607557f79f1"
|
||||
data-domains="www.useplunk.com"
|
||||
/>
|
||||
|
||||
<App {...props} />
|
||||
</SWRConfig>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import {Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArrowRight, Code2, Eye, Mail, Palette, Sparkles, Type, Zap} from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Type className="h-5 w-5" />,
|
||||
title: 'Visual WYSIWYG Editor',
|
||||
description:
|
||||
'Rich text editing with formatting toolbar. Bold, italic, headings, lists, links, images, and tables. No code required.',
|
||||
},
|
||||
{
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
title: 'Full HTML Editor',
|
||||
description:
|
||||
'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
title: 'Smart Mode Switching',
|
||||
description:
|
||||
'Automatically detects complex HTML and switches to code mode. Warns you before changes that would lose custom formatting.',
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
title: 'Powerful Variables',
|
||||
description:
|
||||
'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
|
||||
},
|
||||
{
|
||||
icon: <Eye className="h-5 w-5" />,
|
||||
title: 'Live Preview',
|
||||
description: 'Preview with real contact data. Test on desktop, tablet, and mobile views before sending.',
|
||||
},
|
||||
{
|
||||
icon: <Palette className="h-5 w-5" />,
|
||||
title: 'Email-Safe HTML',
|
||||
description: 'Automatic CSS inlining and email-client-friendly code generation. Yes, even in Outlook.',
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Code2 className="h-6 w-6" />,
|
||||
title: 'For Developers',
|
||||
description:
|
||||
'Full HTML control when you need it. Powerful variable system with autocomplete and fallbacks. Use templates in API calls, workflows, and campaigns.',
|
||||
example: 'Password resets → API-triggered alerts → Webhook notifications → Those cat meme attachments',
|
||||
},
|
||||
{
|
||||
icon: <Palette className="h-6 w-6" />,
|
||||
title: 'For Marketers',
|
||||
description:
|
||||
'Visual editor for quick changes. Live preview with real customer data. Create professional emails without waiting for developers.',
|
||||
example: 'Product announcements → Newsletter campaigns → Promotional emails → Customer onboarding',
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
title: 'For Teams',
|
||||
description:
|
||||
'One tool for everyone. Developers can code, marketers can design, everyone can preview. Reusable templates across campaigns and workflows.',
|
||||
example: 'Launch announcements → Feature updates → User engagement → Lifecycle emails',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function EmailEditorFeature() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk</title>
|
||||
<meta
|
||||
name="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:title"
|
||||
content="Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk"
|
||||
/>
|
||||
<meta
|
||||
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."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero Section */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
|
||||
<Mail className="h-4 w-4 text-neutral-600" />
|
||||
<span className={'font-medium text-neutral-600'}>Email Editor & Templates</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
The Email Editor
|
||||
<br />
|
||||
That Speaks Both Languages
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Switch seamlessly between visual and code editing. Preview with real customer data. Create templates that
|
||||
work everywhere.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Try the editor free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Two editors, one experience
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Visual editing for speed, code editing for control</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className={'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-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
From first draft to send
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>Create, preview, and deploy templates in minutes</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto mt-16 max-w-5xl'}>
|
||||
<div className={'grid gap-12 lg:grid-cols-3'}>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Create your template</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Use the visual editor for quick formatting or write custom HTML. Add variables with autocomplete.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Preview with real data</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Select any contact and see exactly what they'll receive. Test on desktop, tablet, and mobile. No
|
||||
surprises.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Use everywhere</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Use your template in campaigns, workflows, and API calls. One template, unlimited uses.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Built for every team</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Whether you're a developer, marketer, or founder</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-8'}>
|
||||
{useCases.map((useCase, index) => (
|
||||
<motion.div
|
||||
key={useCase.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-6'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{useCase.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
|
||||
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
|
||||
<p className={'text-sm text-neutral-700'}>{useCase.example}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Build your first template today
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started for free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
import {Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArrowRight, Bell, Database, Inbox, Mail, Shield, Zap} from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
title: 'Automatic Contact Capture',
|
||||
description: 'Every sender is automatically added to your contact database with no manual data entry required.',
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
title: 'Workflow Automation',
|
||||
description: 'Trigger automated workflows when emails are received to create sophisticated two-way communication.',
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
title: 'Built-in Security',
|
||||
description:
|
||||
'Spam, virus, SPF, DKIM, and DMARC filtering keeps your inbox clean. The spam stays out, the good stuff gets in.',
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
title: 'Webhook Notifications',
|
||||
description: 'Get instant notifications with rich metadata whenever an email arrives at your domain.',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Simple DNS Setup',
|
||||
description: 'Add one MX record to your domain and start receiving emails immediately. No PhD required.',
|
||||
},
|
||||
{
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
title: 'Real-Time Processing',
|
||||
description: 'Emails are processed instantly and can trigger workflows or webhooks in real-time.',
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Mail className="h-6 w-6" />,
|
||||
title: 'Support Ticket Creation',
|
||||
description:
|
||||
'Automatically create support tickets when customers email support@yourdomain.com. Send auto-replies and route to your help desk system via webhooks. Your support team will thank you.',
|
||||
benefits: ['Instant acknowledgment', 'Automatic ticket creation', 'No emails missed'],
|
||||
},
|
||||
{
|
||||
icon: <Database className="h-6 w-6" />,
|
||||
title: 'Lead Capture from Email',
|
||||
description:
|
||||
'Receive emails at info@yourdomain.com and automatically add senders to your CRM. Trigger nurture workflows based on when they reached out.',
|
||||
benefits: ['Zero-friction lead capture', 'Auto-segmentation', 'Instant follow-up'],
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-6 w-6" />,
|
||||
title: 'Two-Way Conversations',
|
||||
description:
|
||||
'Let customers reply to your campaign emails and automatically trigger engagement workflows. Tag contacts as "engaged" when they respond.',
|
||||
benefits: ['Build conversation history', 'Track engagement', 'Personalized responses'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function InboundEmailFeature() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Inbound Email - Receive & Process Incoming Emails | Plunk</title>
|
||||
<meta
|
||||
name="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:title" content="Inbound Email - Turn Incoming Emails into Automated Actions | Plunk" />
|
||||
<meta
|
||||
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."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero Section */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
|
||||
<Inbox className="h-4 w-4 text-neutral-600" />
|
||||
<span className={'font-medium text-neutral-600'}>Inbound Email</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Turn Incoming Emails
|
||||
<br />
|
||||
into Actions
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Receive emails at your custom domain and automatically trigger workflows, capture leads, or create support
|
||||
tickets. Two-way email communication made simple.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Start receiving emails
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Complete inbound email solution
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||
Everything you need to receive and process incoming emails
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className={'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-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Set up in minutes</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>Get started with inbound email in three simple steps</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto mt-16 max-w-5xl'}>
|
||||
<div className={'grid gap-12 lg:grid-cols-3'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
|
||||
}
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Verify your domain</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Add and verify your custom domain in Plunk by configuring DKIM and SPF records in your DNS settings.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
|
||||
}
|
||||
>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Add MX record</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Add one MX record to your DNS to route incoming emails to Plunk. Copy the record from your dashboard.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
|
||||
}
|
||||
>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Start receiving</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Emails sent to any address at your domain are automatically received and can trigger workflows or
|
||||
webhooks.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Powerful use cases</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||
From support to sales, inbound email unlocks new automation possibilities
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-8'}>
|
||||
{useCases.map((useCase, index) => (
|
||||
<motion.div
|
||||
key={useCase.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-6'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{useCase.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
|
||||
<div className={'mt-4 flex flex-wrap gap-2'}>
|
||||
{useCase.benefits.map(benefit => (
|
||||
<span
|
||||
key={benefit}
|
||||
className={'rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700'}
|
||||
>
|
||||
{benefit}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical Details */}
|
||||
<section className={'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-4xl'}
|
||||
>
|
||||
<div className={'rounded-2xl border border-neutral-200 bg-white p-8 sm:p-12'}>
|
||||
<h2 className={'text-3xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
What happens when an email arrives?
|
||||
</h2>
|
||||
<div className={'mt-10'}>
|
||||
{[
|
||||
{
|
||||
title: 'Email arrives at your domain',
|
||||
description: 'Your MX record routes the email to Plunk for processing',
|
||||
},
|
||||
{
|
||||
title: 'Security checks pass',
|
||||
description: 'Automatic validation of spam, virus, SPF, DKIM, and DMARC',
|
||||
},
|
||||
{
|
||||
title: 'Contact is created or updated',
|
||||
description: 'The sender is automatically added to your contact database',
|
||||
},
|
||||
{
|
||||
title: 'Workflows trigger automatically',
|
||||
description: 'Configured workflows start running based on the incoming email',
|
||||
},
|
||||
].map((step, i, arr) => (
|
||||
<div key={step.title} className={'relative flex gap-6'}>
|
||||
{/* Vertical connector */}
|
||||
{i < arr.length - 1 && (
|
||||
<div className={'absolute left-[1.125rem] top-10 bottom-0 w-px bg-neutral-200'} />
|
||||
)}
|
||||
<div className={'relative flex-shrink-0'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-9 w-9 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-sm font-bold text-neutral-900'
|
||||
}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className={i < arr.length - 1 ? 'pb-8' : ''}>
|
||||
<p className={'font-semibold text-neutral-900'}>{step.title}</p>
|
||||
<p className={'mt-1 text-sm text-neutral-600'}>{step.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Your domain can receive emails too
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Set up inbound email on any verified domain in minutes. Replies, support tickets, and webhooks, all from
|
||||
one platform.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started for free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
import {Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArrowRight, Filter, GitBranch, Mail, Target, TrendingUp, Users} from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Filter className="h-5 w-5" />,
|
||||
title: 'Dynamic Filtering',
|
||||
description:
|
||||
'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
|
||||
},
|
||||
{
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
title: 'Real-Time Updates',
|
||||
description: 'Dynamic segments automatically update as contact data changes, always keeping your audience current.',
|
||||
},
|
||||
{
|
||||
icon: <GitBranch className="h-5 w-5" />,
|
||||
title: 'Workflow Integration',
|
||||
description:
|
||||
'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
|
||||
},
|
||||
{
|
||||
icon: <Target className="h-5 w-5" />,
|
||||
title: 'Campaign Targeting',
|
||||
description:
|
||||
'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
|
||||
},
|
||||
{
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
title: 'Static Segments',
|
||||
description: 'Manually curate contact lists for special groups like beta testers or VIP customers.',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Behavior-Based',
|
||||
description: 'Segment by email engagement - who opened, clicked, bounced, or never received your emails.',
|
||||
},
|
||||
];
|
||||
|
||||
const filterExamples = [
|
||||
{
|
||||
title: 'Active Users',
|
||||
description: 'Target users who signed up recently and are actively engaging',
|
||||
filters: ['Created within 30 days', 'Opened email within 7 days', 'Custom field: plan equals "pro"'],
|
||||
},
|
||||
{
|
||||
title: 'Re-engagement Needed',
|
||||
description: 'Find inactive users who need a nudge to come back. Sometimes they just need a reminder.',
|
||||
filters: ['Last activity older than 60 days', 'Email sent but not opened', 'Subscribed equals true'],
|
||||
},
|
||||
{
|
||||
title: 'High-Value Customers',
|
||||
description: 'Identify your most valuable customers for special treatment',
|
||||
filters: ['Custom field: totalSpent greater than 1000', 'Triggered event: purchase', 'Plan equals "enterprise"'],
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Mail className="h-6 w-6" />,
|
||||
title: 'Targeted Campaigns',
|
||||
description:
|
||||
'Send newsletters and announcements to specific audience segments instead of blasting everyone. Increase open rates by sending relevant content to the right people. Your unsubscribe rate will thank you.',
|
||||
},
|
||||
{
|
||||
icon: <GitBranch className="h-6 w-6" />,
|
||||
title: 'Behavior-Based Workflows',
|
||||
description:
|
||||
'Trigger workflows when contacts enter segments like "VIP Customers" or "Churning Users". Create personalized automations based on segment membership.',
|
||||
},
|
||||
{
|
||||
icon: <Target className="h-6 w-6" />,
|
||||
title: 'A/B Testing',
|
||||
description:
|
||||
'Create segments for test groups and control groups. Send different campaigns to each segment and measure results.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function SegmentsFeature() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Audience Segmentation - Target the Right Contacts | Plunk</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement. Target campaigns and trigger workflows based on segment membership."
|
||||
/>
|
||||
<meta property="og:title" content="Audience Segmentation - Smart Contact Organization | Plunk" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement. Target campaigns and trigger workflows based on segment membership."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero Section */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
|
||||
<Users className="h-4 w-4 text-neutral-600" />
|
||||
<span className={'font-medium text-neutral-600'}>Audience Segmentation</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Target the Right Audience,
|
||||
<br />
|
||||
Every Time
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Organize contacts into dynamic segments based on behavior, attributes, and engagement. Send targeted
|
||||
campaigns and trigger personalized workflows.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Start segmenting
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
The right message to the right person
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Build precise audiences and send campaigns that land</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filter Examples */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Flexible filtering options
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Build complex segments with nested AND/OR logic</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-6'}>
|
||||
{filterExamples.map((example, index) => (
|
||||
<motion.div
|
||||
key={example.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start justify-between gap-6'}>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{example.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{example.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={'mt-6 space-y-2'}>
|
||||
{example.filters.map((filter, filterIndex) => (
|
||||
<div key={filterIndex} className={'flex items-center gap-3 rounded-lg bg-neutral-50 px-4 py-3'}>
|
||||
<Filter className="h-4 w-4 flex-shrink-0 text-neutral-400" />
|
||||
<span className={'font-mono text-sm text-neutral-700'}>{filter}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Types of Segments */}
|
||||
<section className={'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-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Two types of segments</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>Choose between dynamic filtering or manual curation</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto mt-16 max-w-5xl'}>
|
||||
<div className={'grid gap-8 lg:grid-cols-2'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
|
||||
<TrendingUp className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segments</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>
|
||||
Automatically update based on filter conditions. As contact data changes, segment membership updates
|
||||
in real-time.
|
||||
</p>
|
||||
<div className={'mt-6 space-y-2'}>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Filter-based membership</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Automatic updates</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Optional entry/exit tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
|
||||
<Users className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Static Segments</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>
|
||||
Manually curate your segment by adding specific contacts. Membership stays fixed until you change it.
|
||||
</p>
|
||||
<div className={'mt-6 space-y-2'}>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Manual contact selection</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Fixed membership</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
||||
<span>Perfect for VIP lists</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Use segments everywhere
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>From targeted campaigns to automated workflows</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-8'}>
|
||||
{useCases.map((useCase, index) => (
|
||||
<motion.div
|
||||
key={useCase.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-6'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{useCase.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Stop sending the same email to everyone
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Build precise audience segments and watch your open rates climb. 1,000 emails free, no credit card required.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started for free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArrowRight, Code2, Lock, Mail, Server, Settings, Shield} from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
title: 'Simple Configuration',
|
||||
description: 'Quick setup with your project credentials. Works with any email client or application.',
|
||||
},
|
||||
{
|
||||
icon: <Lock className="h-5 w-5" />,
|
||||
title: 'Secure Connections',
|
||||
description: 'TLS/SSL encryption on ports 465 and 587. Your emails are always transmitted securely.',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Universal Compatibility',
|
||||
description:
|
||||
'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application. Even that ancient email client from 2005.',
|
||||
},
|
||||
{
|
||||
icon: <Server className="h-5 w-5" />,
|
||||
title: 'Domain Validation',
|
||||
description: 'Automatic verification that your sender domain is verified before accepting emails.',
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
title: 'Full Feature Support',
|
||||
description:
|
||||
'Attachments, custom headers, HTML emails, and multiple recipients. Send those cat memes with confidence.',
|
||||
},
|
||||
{
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
title: 'Same Infrastructure',
|
||||
description: 'SMTP emails use the same reliable delivery infrastructure as API emails with full tracking.',
|
||||
},
|
||||
];
|
||||
|
||||
const comparisonData = [
|
||||
{feature: 'Protocol', traditional: 'SMTP', plunkSMTP: 'SMTP', plunkAPI: 'HTTP/REST'},
|
||||
{feature: 'Setup Complexity', traditional: 'Medium', plunkSMTP: 'Easy', plunkAPI: 'Easy'},
|
||||
{feature: 'Tracking & Analytics', traditional: '✗', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||
{feature: 'Works with Email Clients', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✗'},
|
||||
{feature: 'Domain Verification', traditional: 'Manual', plunkSMTP: 'Automatic', plunkAPI: 'Automatic'},
|
||||
{feature: 'Attachments', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||
{feature: 'Template Support', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||
{feature: 'Workflow Automation', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
icon: <Settings className="h-6 w-6" />,
|
||||
title: 'Legacy System Integration',
|
||||
description:
|
||||
'Already have applications using SMTP? No need to rewrite code. Just swap your SMTP credentials and keep everything else the same. Your PM will love you.',
|
||||
benefit: 'Zero code changes required',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-6 w-6" />,
|
||||
title: 'Email Client Sending',
|
||||
description:
|
||||
'Marketing teams can send emails directly from Outlook, Thunderbird, or Apple Mail using familiar tools without learning new APIs.',
|
||||
benefit: 'No technical knowledge needed',
|
||||
},
|
||||
{
|
||||
icon: <Code2 className="h-6 w-6" />,
|
||||
title: 'Framework Compatibility',
|
||||
description:
|
||||
'Works with any framework or language that supports SMTP. Perfect for older systems or platforms without HTTP API support.',
|
||||
benefit: 'Universal protocol support',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function SMTPFeature() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>SMTP Email Sending - Send via SMTP or API | Plunk</title>
|
||||
<meta
|
||||
name="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:title" content="SMTP Email Sending - Flexible Sending Options | Plunk" />
|
||||
<meta
|
||||
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."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero Section */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
|
||||
<Server className="h-4 w-4 text-neutral-600" />
|
||||
<span className={'font-medium text-neutral-600'}>SMTP Email Sending</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Send Emails via SMTP or API
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Use our HTTP API for modern apps or drop in SMTP credentials for any legacy system. Same deliverability, same pricing, zero lock-in.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get SMTP credentials
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP that works with everything</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Full authentication, tracking, and deliverability out of the box</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section className={'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={'mb-12 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP vs API</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Choose the right option for your use case</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-4xl'}>
|
||||
<div className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}>
|
||||
<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-center text-sm font-semibold text-neutral-900'}>Traditional SMTP</th>
|
||||
<th className={'bg-neutral-100 px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>
|
||||
Plunk SMTP
|
||||
</th>
|
||||
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>Plunk API</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={'divide-y divide-neutral-200'}>
|
||||
{comparisonData.map((row, index) => (
|
||||
<tr key={index} className={'transition hover:bg-neutral-50'}>
|
||||
<td className={'px-6 py-4 text-sm text-neutral-900'}>{row.feature}</td>
|
||||
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.traditional}</td>
|
||||
<td className={'bg-neutral-50 px-6 py-4 text-center text-sm font-medium text-neutral-900'}>
|
||||
{row.plunkSMTP}
|
||||
</td>
|
||||
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.plunkAPI}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={'mt-6 rounded-lg bg-neutral-50 p-4 text-center'}>
|
||||
<p className={'text-sm text-neutral-600'}>
|
||||
<strong>Recommendation:</strong> Use API for modern applications with workflow automation. Use SMTP for
|
||||
email clients and legacy systems.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>When to use SMTP</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Perfect for these scenarios</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-8'}>
|
||||
{useCases.map((useCase, index) => (
|
||||
<motion.div
|
||||
key={useCase.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-6'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{useCase.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
|
||||
<div className={'mt-4 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2'}>
|
||||
<div className={'h-2 w-2 rounded-full bg-green-500'} />
|
||||
<span className={'text-sm font-medium text-neutral-700'}>{useCase.benefit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Start sending via SMTP</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Get your SMTP credentials and start sending emails from any client or application. No credit card
|
||||
required.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started for free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import {Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {ArrowRight, Clock, GitBranch, Mail, RefreshCw, UserPlus, Webhook, Zap} from 'lucide-react';
|
||||
import Head from 'next/head';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
title: 'Event-Driven Triggers',
|
||||
description: 'Start workflows automatically when users sign up, make a purchase, or perform any custom action.',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Smart Email Sequences',
|
||||
description: 'Send personalized emails at the right time with dynamic content based on user data.',
|
||||
},
|
||||
{
|
||||
icon: <Clock className="h-5 w-5" />,
|
||||
title: 'Time-Based Delays',
|
||||
description: 'Add strategic delays between steps to create perfectly timed email journeys. Patience is a virtue.',
|
||||
},
|
||||
{
|
||||
icon: <GitBranch className="h-5 w-5" />,
|
||||
title: 'Conditional Logic',
|
||||
description: 'Branch workflows based on user behavior, attributes, or engagement to personalize every journey.',
|
||||
},
|
||||
{
|
||||
icon: <Webhook className="h-5 w-5" />,
|
||||
title: 'External Integrations',
|
||||
description: 'Connect to external systems with webhooks to sync data or trigger actions outside of Plunk.',
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
title: 'Re-entry Control',
|
||||
description: 'Decide whether contacts can enter workflows multiple times or just once. No spam, just strategy.',
|
||||
},
|
||||
];
|
||||
|
||||
const useCases = [
|
||||
{
|
||||
icon: <UserPlus className="h-6 w-6" />,
|
||||
title: 'User Onboarding',
|
||||
description:
|
||||
'Welcome new users with a personalized email series that guides them through your product features and helps them get started.',
|
||||
example: 'Trigger on signup → Send welcome email → Wait 2 days → Send getting started tips',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-6 w-6" />,
|
||||
title: 'Abandoned Cart Recovery',
|
||||
description:
|
||||
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives. Those forgotten items need a gentle nudge.',
|
||||
example: 'Trigger on cart abandoned → Wait 1 hour → Send reminder → Wait 1 day → Send discount offer',
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-6 w-6" />,
|
||||
title: 'Re-engagement Campaigns',
|
||||
description:
|
||||
'Win back inactive users with targeted campaigns based on their last activity and engagement patterns.',
|
||||
example: 'Trigger on 30 days inactive → Check if opened last email → Yes: Send update / No: Send special offer',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function WorkflowsFeature() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Email Workflow Automation | Plunk</title>
|
||||
<meta
|
||||
name="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:title" content="Email Workflow Automation - Automate Your Email Marketing | Plunk" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero Section */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
{/* Subtle background grid */}
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
|
||||
<Zap className="h-4 w-4 text-neutral-600" />
|
||||
<span className={'font-medium text-neutral-600'}>Workflow Automation</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Email Automation
|
||||
<br />
|
||||
That Actually Works
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Turn events into personalized email journeys. Build sophisticated automation workflows with our visual
|
||||
no-code builder.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Start building workflows
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
target={'_blank'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Everything you need for email automation
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Powerful features that make complex automations simple</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
||||
}
|
||||
>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className={'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-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Visual workflow builder
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Create complex email automations without writing a single line of code
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto mt-16 max-w-5xl'}>
|
||||
<div className={'grid gap-12 lg:grid-cols-3'}>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Choose a trigger</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Select an event that starts your workflow, like user signup, purchase, or any custom action you track.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Build your flow</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Drag and drop steps to create your workflow. Add emails, delays, conditions, webhooks, and more.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-5 flex items-center gap-4'}>
|
||||
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={'text-lg font-semibold text-neutral-900'}>Activate and monitor</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Enable your workflow and watch it run automatically. Monitor executions in real-time with full
|
||||
visibility.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Use Cases */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Built for every use case
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>From onboarding to re-engagement, workflows handle it all</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'mx-auto max-w-5xl space-y-8'}>
|
||||
{useCases.map((useCase, index) => (
|
||||
<motion.div
|
||||
key={useCase.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-xl border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-6'}>
|
||||
<div
|
||||
className={
|
||||
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||
}
|
||||
>
|
||||
{useCase.icon}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
|
||||
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
|
||||
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
|
||||
<p className={'font-mono text-sm text-neutral-700'}>{useCase.example}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Set up your first workflow in minutes
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started for free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'/pricing'}
|
||||
className={
|
||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
||||
}
|
||||
>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="15 min"
|
||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="11 min"
|
||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
|
||||
<NextSeo
|
||||
title="Email Marketing & Deliverability Guides | Plunk"
|
||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||
canonical="https://next.useplunk.com/guides"
|
||||
canonical="https://www.useplunk.com/guides"
|
||||
openGraph={{
|
||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://next.useplunk.com/guides',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -224,7 +224,12 @@ export default function GuidesIndex() {
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -245,7 +250,7 @@ export default function GuidesIndex() {
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
Get started free
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-dmarc"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
|
||||
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://next.useplunk.com/guides/what-is-spf"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Clock,
|
||||
Code2,
|
||||
Globe,
|
||||
Inbox,
|
||||
Lock,
|
||||
Mail,
|
||||
Megaphone,
|
||||
@@ -72,17 +72,18 @@ const features = [
|
||||
{
|
||||
icon: <Workflow className="h-5 w-5" />,
|
||||
title: 'Workflow Automation',
|
||||
description: 'Visual builder for complex email sequences with triggers, delays, and conditional logic.',
|
||||
description:
|
||||
'Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.',
|
||||
},
|
||||
{
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
title: 'Dynamic Segments',
|
||||
description: 'Real-time audience segmentation based on contact data and behavior.',
|
||||
description: 'Real-time audience segmentation based on contact data and behavior. Less noise, more signal.',
|
||||
},
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Campaign Management',
|
||||
description: 'Broadcast emails with scheduling and performance tracking.',
|
||||
description: 'Broadcast emails with scheduling and performance tracking. Send the right message at the right time.',
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
@@ -90,14 +91,14 @@ const features = [
|
||||
description: 'Detailed metrics on opens, clicks, bounces, and conversions across campaigns.',
|
||||
},
|
||||
{
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
title: 'Developer API',
|
||||
description: 'RESTful API with comprehensive documentation.',
|
||||
icon: <Inbox className="h-5 w-5" />,
|
||||
title: 'Inbound Email',
|
||||
description: 'Receive and process incoming emails with webhook notifications. Your inbox, automated.',
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
title: 'Custom Domains',
|
||||
description: 'Brand consistency with DKIM authentication and custom sending domains.',
|
||||
description: 'Brand consistency with DKIM authentication and custom sending domains. Yes, even in Outlook.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -224,13 +225,36 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<h1 className={'text-7xl font-bold tracking-tight text-neutral-900 sm:text-8xl lg:text-9xl'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 10}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'mb-8 inline-flex flex-col sm:flex-row items-center gap-x-2 gap-y-1 rounded-full border border-neutral-200 bg-white px-4 py-2 text-sm shadow-sm'
|
||||
}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
<svg className="h-4 w-4 text-neutral-700" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className={'font-medium text-neutral-700'}>5,000+ Stars on GitHub</span>
|
||||
</span>
|
||||
<span className={'hidden sm:inline text-neutral-300'}>·</span>
|
||||
<span className={'text-neutral-500'}>AGPL-3.0 Open Source</span>
|
||||
</motion.div>
|
||||
|
||||
<h1 className={'text-7xl font-bold tracking-tight text-neutral-900 sm:text-8xl lg:text-9xl text-balance'}>
|
||||
Open-Source
|
||||
<br />
|
||||
Email Platform
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Open-source email automation. Build workflows, segment audiences, and send emails with a simple API.
|
||||
Transactional emails, marketing campaigns, and workflow automation in one platform. Open-source,
|
||||
self-hostable, $0.001 per email, no contact limits.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -269,14 +293,14 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900'}>
|
||||
Replace Resend, SendGrid, Mailchimp, and More
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Replace your email stack
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||
The open-source alternative to proprietary email platforms
|
||||
The open-source alternative to Resend, SendGrid, Mailchimp, and more
|
||||
</p>
|
||||
|
||||
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-5'}>
|
||||
<div className={'mt-16 grid grid-cols-2 gap-px bg-neutral-200 lg:grid-cols-5'}>
|
||||
<Link href={'/vs/resend'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
|
||||
<div className={'flex flex-col items-center gap-4'}>
|
||||
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
|
||||
@@ -334,7 +358,7 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Most email tools weren't built to scale
|
||||
</h2>
|
||||
</motion.div>
|
||||
@@ -355,7 +379,9 @@ export default function Index() {
|
||||
<Clock className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Complex setup</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Hours of configuration needed</p>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Most email platforms take days to configure. Plunk is up and running in under 5 minutes.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -373,7 +399,9 @@ export default function Index() {
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Contact limits</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Growth penalties</p>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Other platforms charge more as your list grows. Plunk stores unlimited contacts for free.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -391,7 +419,9 @@ export default function Index() {
|
||||
<Lock className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Vendor lock-in</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Closed source platforms</p>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||
Closed-source platforms own your stack. Plunk is AGPL-3.0 licensed and fully self-hostable.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -405,7 +435,7 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Built for scale</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Built for scale</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to run email at any volume</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -442,11 +472,12 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
One contact, unified across everything
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
One contact, complete history
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Every interaction flows into a single contact record with complete history
|
||||
Every interaction flows into a single contact record. Transactional emails, campaigns, and workflows, all
|
||||
tracked in one place.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -602,31 +633,40 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Open source, privacy first</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Open source, privacy first
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Transparent codebase, privacy-focused infrastructure, and the option to self-host
|
||||
AGPL-3.0 licensed, EU-hosted, and GDPR compliant. Inspect the code, self-host on your own infrastructure,
|
||||
or use our cloud.
|
||||
</p>
|
||||
|
||||
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||
<div className={'bg-white p-12'}>
|
||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
||||
<PackageOpen className={'h-7 w-7 text-neutral-900'} />
|
||||
<div
|
||||
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||
>
|
||||
<PackageOpen className={'h-7 w-7'} />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>AGPL-3.0 licensed</p>
|
||||
<p className={'mt-4 text-xs text-neutral-500'}>4K+ stars on GitHub</p>
|
||||
<p className={'mt-4 text-xs text-neutral-500'}>5K+ stars on GitHub</p>
|
||||
</div>
|
||||
<div className={'bg-white p-12'}>
|
||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
||||
<Shield className={'h-7 w-7 text-neutral-900'} />
|
||||
<div
|
||||
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||
>
|
||||
<Shield className={'h-7 w-7'} />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Privacy First</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>EU hosted</p>
|
||||
<p className={'mt-4 text-xs text-neutral-500'}>GDPR compliant</p>
|
||||
</div>
|
||||
<div className={'bg-white p-12'}>
|
||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
||||
<Globe className={'h-7 w-7 text-neutral-900'} />
|
||||
<div
|
||||
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||
>
|
||||
<Globe className={'h-7 w-7'} />
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>Deploy anywhere</p>
|
||||
@@ -660,8 +700,12 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Simple, transparent pricing</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>Pay for what you use, nothing more</p>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Simple, transparent pricing
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Pay for what you use, nothing more. No surprises at scale.
|
||||
</p>
|
||||
|
||||
<div className={'mt-20'}>
|
||||
<div className={'flex items-baseline justify-center gap-3'}>
|
||||
@@ -735,7 +779,7 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
>
|
||||
<div className={'mb-20 text-center'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Trusted by the best</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Trusted by the best</h2>
|
||||
</div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -748,9 +792,8 @@ export default function Index() {
|
||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col bg-white p-10'}
|
||||
>
|
||||
<p className={'min-h-[3.5rem] text-sm leading-relaxed text-neutral-600'}>
|
||||
“{t.testimonial}”
|
||||
</p>
|
||||
<div className={'mb-2 font-serif text-5xl leading-none text-neutral-200'}>“</div>
|
||||
<p className={'text-sm leading-relaxed text-neutral-700'}>{t.testimonial}</p>
|
||||
<div className={'mt-auto flex items-center gap-4 pt-6'}>
|
||||
<div className={'relative h-12 w-12 overflow-hidden rounded-full'}>
|
||||
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
|
||||
@@ -767,7 +810,12 @@ export default function Index() {
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -775,9 +823,11 @@ export default function Index() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to get started?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Start sending in 5 minutes
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join thousands of businesses building better email experiences with Plunk
|
||||
1,000 emails free every month. Then $0.001 per email. No contact limits, no surprises.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
|
||||
+251
-254
@@ -2,14 +2,63 @@ import {NextSeo} from 'next-seo';
|
||||
import React from 'react';
|
||||
import {Footer, Navbar} from '../components';
|
||||
import {motion} from 'framer-motion';
|
||||
|
||||
import {DASHBOARD_URI} from '../lib/constants';
|
||||
import {ArrowRight, BarChart3, Code2, Globe, Mail, PackageOpen, Shield, Users, Zap, X, Check} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {GithubIcon} from 'lucide-react';
|
||||
|
||||
const includedFeatures = [
|
||||
{
|
||||
icon: <Mail className="h-5 w-5" />,
|
||||
title: 'Transactional emails',
|
||||
description: 'API and SMTP delivery for receipts, password resets, and any event-driven email.',
|
||||
},
|
||||
{
|
||||
icon: <Zap className="h-5 w-5" />,
|
||||
title: 'Workflow automation',
|
||||
description: 'Build event-triggered sequences with delays, conditions, and branching logic.',
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
title: 'Campaign broadcasts',
|
||||
description: 'Send newsletters and announcements to your full list or a targeted segment.',
|
||||
},
|
||||
{
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
title: 'Unlimited contacts',
|
||||
description: 'Store as many contacts as you need. Growing your list never costs more.',
|
||||
},
|
||||
{
|
||||
icon: <Code2 className="h-5 w-5" />,
|
||||
title: 'Full API access',
|
||||
description: 'REST API with SDKs for Node.js, Python, and more. Comprehensive documentation.',
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
title: 'Custom domains',
|
||||
description: 'Send from your own domain with DKIM, SPF, and DMARC set up automatically.',
|
||||
},
|
||||
{
|
||||
icon: <Globe className="h-5 w-5" />,
|
||||
title: 'Audience segmentation',
|
||||
description: 'Dynamic segments built on behavior, attributes, and engagement data.',
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
title: 'Analytics & tracking',
|
||||
description: 'Opens, clicks, bounces, and unsubscribes. Real data, no guessing.',
|
||||
},
|
||||
{
|
||||
icon: <PackageOpen className="h-5 w-5" />,
|
||||
title: 'Open source',
|
||||
description: 'AGPL-3.0 licensed. Inspect the code, self-host it, or contribute to it.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function Index() {
|
||||
export default function Pricing() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
@@ -32,273 +81,221 @@ export default function Index() {
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
<main>
|
||||
<section className={'py-32'}>
|
||||
<div className={'mx-auto max-w-4xl text-center'}>
|
||||
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl">
|
||||
Simple, transparent pricing
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">Start free, pay only for what you use</p>
|
||||
</div>
|
||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||
{/* Hero */}
|
||||
<section className={'relative py-20 sm:py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Pricing Tiers */}
|
||||
<div className="mx-auto mt-20 grid max-w-5xl gap-8 lg:grid-cols-2">
|
||||
{/* Free Tier */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{delay: 0.2}}
|
||||
className="relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-neutral-900 via-neutral-800 to-neutral-900 p-10 shadow-2xl"
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-4xl text-center'}
|
||||
>
|
||||
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl text-balance">
|
||||
Simple, transparent pricing
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">
|
||||
1,000 emails free every month. Then $0.001 per email. Unlimited contacts, no hidden fees.
|
||||
</p>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Pricing tiers */}
|
||||
<section className={'pb-20'}>
|
||||
<div className={'mx-auto grid max-w-4xl gap-px bg-neutral-200 sm:grid-cols-2'}>
|
||||
{/* Free */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col bg-white p-10'}
|
||||
>
|
||||
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Free forever</p>
|
||||
<div className={'mt-4 flex items-baseline gap-2'}>
|
||||
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>1,000</span>
|
||||
<span className={'text-lg text-neutral-500'}>emails / mo</span>
|
||||
</div>
|
||||
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
|
||||
|
||||
<ul className={'mt-8 flex-1 space-y-3'}>
|
||||
{['Transactional emails', 'Workflow automation', 'Campaign broadcasts', 'Custom domains', 'Click & open tracking', 'Unlimited contacts'].map(item => (
|
||||
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
|
||||
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
|
||||
<X className={'h-4 w-4 flex-shrink-0'} />
|
||||
Plunk branding on emails
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<motion.a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
className={'mt-10 block w-full rounded-lg border border-neutral-300 px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:border-neutral-400'}
|
||||
>
|
||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
||||
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
||||
Start for free
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<div className="mb-6 inline-block self-start rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
|
||||
Free Forever
|
||||
</div>
|
||||
{/* Pay as you grow */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col bg-white p-10'}
|
||||
>
|
||||
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Pay as you grow</p>
|
||||
<div className={'mt-4 flex items-baseline gap-2'}>
|
||||
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>$0.001</span>
|
||||
<span className={'text-lg text-neutral-500'}>/ email</span>
|
||||
</div>
|
||||
<p className={'mt-2 text-sm'}> </p>
|
||||
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<span className="text-6xl font-bold tracking-tight text-white">1,000</span>
|
||||
<span className="text-xl font-medium text-white/60">emails</span>
|
||||
</div>
|
||||
<p className="mb-8 text-base text-white/60">per month</p>
|
||||
<ul className={'mt-8 flex-1 space-y-3'}>
|
||||
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
|
||||
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
|
||||
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul className="flex-1 space-y-3">
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Transactional emails</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Workflow automation</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Campaign broadcasts</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Custom domains</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Click & open tracking</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/80">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>No credit card required</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-white/60">
|
||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Plunk branding</span>
|
||||
</li>
|
||||
</ul>
|
||||
<motion.a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
className={'mt-10 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800'}
|
||||
>
|
||||
Get started
|
||||
|
||||
<motion.a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
className="mt-8 block w-full rounded-lg border-2 border-white bg-white px-6 py-3 text-center font-semibold text-neutral-900 transition hover:bg-white/90"
|
||||
>
|
||||
Start for free
|
||||
</motion.a>
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Every feature included */}
|
||||
<section className={'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={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Every feature on every plan
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>No feature tiers, no add-ons, no surprises</p>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{includedFeatures.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'group bg-white p-10 transition hover:bg-neutral-50'}
|
||||
>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
|
||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pay as you grow */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{delay: 0.3}}
|
||||
className="relative flex flex-col overflow-hidden rounded-2xl border-2 border-neutral-200 bg-white p-10 shadow-lg"
|
||||
>
|
||||
<div className="mb-6 inline-block self-start rounded-full bg-neutral-100 px-4 py-1.5 text-sm font-semibold text-neutral-900">
|
||||
Pay as you grow
|
||||
{/* Self-host */}
|
||||
<section className={'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={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}
|
||||
>
|
||||
<div className={'flex flex-col items-center gap-6 p-10 sm:flex-row sm:gap-0'}>
|
||||
<div className={'flex-1 text-center sm:text-left'}>
|
||||
<div className={'mb-3 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-3 py-1 text-sm font-medium text-neutral-700'}>
|
||||
<PackageOpen className={'h-3.5 w-3.5'} />
|
||||
Self-hostable
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-baseline gap-2">
|
||||
<span className="text-6xl font-bold tracking-tight text-neutral-900">$0.001</span>
|
||||
<span className="text-xl font-medium text-neutral-500">per email</span>
|
||||
</div>
|
||||
<p className="mb-8 text-base text-neutral-500">No contact limits, pay only for what you send</p>
|
||||
|
||||
<ul className="flex-1 space-y-3">
|
||||
<li className="flex items-start gap-3 text-neutral-600">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Everything in Free</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-neutral-600">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>No Plunk branding</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-neutral-600">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Billing limits</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3 text-neutral-600">
|
||||
<svg
|
||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>Unlimited emails</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<motion.a
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
<h2 className={'text-2xl font-bold text-neutral-900'}>Run it on your own infrastructure</h2>
|
||||
<p className={'mt-2 text-neutral-600'}>
|
||||
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose in minutes.
|
||||
</p>
|
||||
</div>
|
||||
<div className={'sm:ml-auto sm:pl-8'}>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.open('https://github.com/useplunk/plunk', '_blank');
|
||||
}}
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
className="mt-8 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center font-semibold text-white transition hover:bg-neutral-800"
|
||||
className={'flex w-full items-center justify-center gap-x-3 rounded-lg bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'}
|
||||
>
|
||||
Start for free
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="mx-auto mt-24 max-w-5xl">
|
||||
<h2 className="mb-12 text-center text-2xl font-bold text-neutral-900">Everything included</h2>
|
||||
<div className="grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited emails</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
Send unlimited transactional, campaign, and automated emails
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited contacts</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
No limits on your audience size. Grow without restrictions
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Custom domains</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">Send from your own domain with full authentication</p>
|
||||
</div>
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Analytics & tracking</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">Detailed metrics on opens, clicks, and conversions</p>
|
||||
</div>
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">Workflow automation</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">Build complex email sequences with visual editor</p>
|
||||
</div>
|
||||
<div className="bg-white p-8">
|
||||
<h3 className="text-lg font-semibold text-neutral-900">API access</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600">Full REST API with comprehensive documentation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 overflow-hidden rounded-lg border border-neutral-200">
|
||||
<div className="flex flex-col items-center space-y-6 p-10 sm:flex-row sm:space-y-0">
|
||||
<div className="flex-1 text-center sm:text-left">
|
||||
<h2 className={'text-2xl font-bold text-neutral-900'}>Self-host</h2>
|
||||
<p className={'mt-2 text-neutral-600'}>
|
||||
Host Plunk on your own infrastructure. The perfect solution for when you require full control.
|
||||
</p>
|
||||
</div>
|
||||
<div className={'sm:ml-auto'}>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
window.open('https://github.com/useplunk/plunk', '_blank');
|
||||
}}
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
className={
|
||||
'flex w-full items-center justify-center gap-x-3 rounded-lg bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'
|
||||
}
|
||||
>
|
||||
<GithubIcon size={20} />
|
||||
View GitHub
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
<GithubIcon size={18} />
|
||||
View on GitHub
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<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-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
|
||||
Start sending in 5 minutes
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails free every month. No credit card required.
|
||||
</p>
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'}
|
||||
>
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Create free account
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={'https://github.com/useplunk/plunk'}
|
||||
target={'_blank'}
|
||||
className={'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'}
|
||||
>
|
||||
Self-host for free
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
|
||||
@@ -32,13 +32,13 @@ export default function ToolsIndex() {
|
||||
<NextSeo
|
||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
canonical="https://next.useplunk.com/tools"
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
url: 'https://next.useplunk.com/tools',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function ToolsIndex() {
|
||||
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Free tools for
|
||||
<br />
|
||||
email developers
|
||||
@@ -114,7 +114,7 @@ export default function ToolsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Available Tools</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Available Tools</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to work with emails</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -168,7 +168,7 @@ export default function ToolsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why use these tools?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why use these tools?</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function ToolsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Need production-grade email tools?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Need production-grade email tools?</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
These free tools are great for development, but Plunk offers so much more: templates, scheduling,
|
||||
automation, analytics, and deliverability optimization. Start free, scale as you grow.
|
||||
|
||||
@@ -28,13 +28,13 @@ export default function MarkdownToEmail() {
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
canonical="https://next.useplunk.com/tools/markdown-to-email"
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
url: 'https://next.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function MarkdownToEmail() {
|
||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Markdown to Email
|
||||
<br />
|
||||
HTML Converter
|
||||
@@ -134,7 +134,7 @@ export default function MarkdownToEmail() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to send great emails?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to send great emails?</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
|
||||
and deliverability. Start free, no credit card required.
|
||||
|
||||
@@ -37,12 +37,12 @@ export default function VerifyEmailPage() {
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
canonical="https://next.useplunk.com/tools/verify-email"
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
url: 'https://next.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ export default function VerifyEmailPage() {
|
||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Email Verification
|
||||
<br />
|
||||
Tool
|
||||
@@ -159,7 +159,7 @@ export default function VerifyEmailPage() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why verify email addresses?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why verify email addresses?</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||
Email verification helps improve deliverability and protect your sender reputation.
|
||||
</p>
|
||||
@@ -201,7 +201,7 @@ export default function VerifyEmailPage() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Ready for production-grade email verification?
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
|
||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose ActiveCampaign over Plunk?',
|
||||
answer:
|
||||
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity—perfect for developers who want to integrate email into their product without CRM overhead.',
|
||||
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity. It is built for developers who want to integrate email into their product without CRM overhead.',
|
||||
},
|
||||
{
|
||||
question: 'What is the price difference between Plunk and ActiveCampaign?',
|
||||
answer:
|
||||
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing—you only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
||||
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing. You only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
||||
},
|
||||
{
|
||||
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
||||
@@ -40,7 +40,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Is migration from ActiveCampaign to Plunk difficult?',
|
||||
answer:
|
||||
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward—export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
||||
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward: export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
||||
},
|
||||
{
|
||||
question: 'What ActiveCampaign features does Plunk not have?',
|
||||
@@ -58,13 +58,13 @@ export default function ActiveCampaignComparison() {
|
||||
<NextSeo
|
||||
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
||||
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
||||
canonical="https://next.useplunk.com/vs/activecampaign"
|
||||
canonical="https://www.useplunk.com/vs/activecampaign"
|
||||
openGraph={{
|
||||
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://next.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function ActiveCampaignComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ActiveCampaign</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for ActiveCampaign
|
||||
@@ -141,7 +141,7 @@ export default function ActiveCampaignComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Enterprise Features Without Enterprise Prices
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
|
||||
@@ -233,7 +233,7 @@ export default function ActiveCampaignComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Automation Without Complexity</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Automation Without Complexity</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -374,7 +374,7 @@ export default function ActiveCampaignComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
|
||||
@@ -384,7 +384,12 @@ export default function ActiveCampaignComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -392,7 +397,7 @@ export default function ActiveCampaignComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Get email automation without the bloat
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
@@ -405,10 +410,13 @@ export default function ActiveCampaignComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -25,17 +25,17 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Bento over Plunk?',
|
||||
answer:
|
||||
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.",
|
||||
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat. It's built specifically for integrating email into your product.",
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Bento?',
|
||||
answer:
|
||||
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed—you only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
||||
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed. You only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk handle email automation like Bento?',
|
||||
answer:
|
||||
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns—all the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
||||
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns. All the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
||||
},
|
||||
{
|
||||
question: 'How does the developer experience compare?',
|
||||
@@ -58,13 +58,13 @@ export default function BentoComparison() {
|
||||
<NextSeo
|
||||
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
||||
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
||||
canonical="https://next.useplunk.com/vs/bento"
|
||||
canonical="https://www.useplunk.com/vs/bento"
|
||||
openGraph={{
|
||||
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://next.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function BentoComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Bento</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Bento
|
||||
@@ -141,7 +141,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Focused Email vs All-in-One Complexity
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
|
||||
@@ -233,7 +233,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Developer-First Email Platform</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Developer-First Email Platform</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -374,7 +374,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Bento" rows={comparisonData} />
|
||||
@@ -384,7 +384,12 @@ export default function BentoComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -392,7 +397,7 @@ export default function BentoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get focused email automation</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get focused email automation</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
|
||||
required.
|
||||
@@ -403,10 +408,13 @@ export default function BentoComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function BrevoComparison() {
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
canonical="https://next.useplunk.com/vs/brevo"
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
url: 'https://next.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function BrevoComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Brevo</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Brevo
|
||||
@@ -140,7 +140,7 @@ export default function BrevoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pricing That Scales With You</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pricing That Scales With You</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function BrevoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Brevo</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Brevo</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -372,7 +372,7 @@ export default function BrevoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -383,7 +383,12 @@ export default function BrevoComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -391,7 +396,7 @@ export default function BrevoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch to simplicity</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch to simplicity</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
|
||||
</p>
|
||||
@@ -401,10 +406,13 @@ export default function BrevoComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -30,12 +30,12 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and ConvertKit?',
|
||||
answer:
|
||||
'ConvertKit charges based on subscriber count—your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
||||
'ConvertKit charges based on subscriber count. Your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk handle creator workflows like ConvertKit?',
|
||||
answer:
|
||||
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features—it's focused purely on email delivery and automation.",
|
||||
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features. It's focused purely on email delivery and automation.",
|
||||
},
|
||||
{
|
||||
question: 'Does Plunk have landing pages like ConvertKit?',
|
||||
@@ -57,14 +57,14 @@ export default function ConvertkitComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="ConvertKit Alternative for Developers | Plunk"
|
||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation—no landing page bloat."
|
||||
canonical="https://next.useplunk.com/vs/convertkit"
|
||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation. No landing page bloat."
|
||||
canonical="https://www.useplunk.com/vs/convertkit"
|
||||
openGraph={{
|
||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://next.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function ConvertkitComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ConvertKit</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for ConvertKit
|
||||
@@ -141,7 +141,7 @@ export default function ConvertkitComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pay for Emails, Not Subscribers</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pay for Emails, Not Subscribers</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function ConvertkitComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Built for Developers, Not Creators</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Built for Developers, Not Creators</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -372,7 +372,7 @@ export default function ConvertkitComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
|
||||
@@ -382,7 +382,12 @@ export default function ConvertkitComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -390,7 +395,7 @@ export default function ConvertkitComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Ready for a developer-first email platform?
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
@@ -403,10 +408,13 @@ export default function ConvertkitComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function CustomerioComparison() {
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
canonical="https://next.useplunk.com/vs/customerio"
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
url: 'https://next.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -89,15 +89,14 @@ export default function CustomerioComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Customer.io</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Customer.io
|
||||
</h1>
|
||||
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Customer.io is powerful but complex. Plunk gives developers the automation they need without the
|
||||
enterprise overhead.
|
||||
Customer.io starts at $100/month and takes weeks to set up. Plunk delivers the same event-driven automation in an open-source platform you can be sending from in under 5 minutes.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -136,7 +135,7 @@ export default function CustomerioComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Simple Pricing That Scales</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Simple Pricing That Scales</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -226,7 +225,7 @@ export default function CustomerioComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
|
||||
@@ -241,7 +240,7 @@ export default function CustomerioComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Key Advantages</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Key Advantages</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -378,7 +377,12 @@ export default function CustomerioComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -386,7 +390,7 @@ export default function CustomerioComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails/month free. No credit card required. Developer-friendly from day one.
|
||||
</p>
|
||||
@@ -396,10 +400,13 @@ export default function CustomerioComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
|
||||
@@ -96,13 +96,13 @@ export default function CompetitorsIndex() {
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
canonical="https://next.useplunk.com/vs"
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
url: 'https://next.useplunk.com/vs',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -131,15 +131,14 @@ export default function CompetitorsIndex() {
|
||||
<span className={'text-sm text-neutral-600'}>Email Platform Comparisons</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Plunk vs the
|
||||
<br />
|
||||
competition
|
||||
</h1>
|
||||
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Compare Plunk with leading email platforms. See why developers choose our open-source alternative for
|
||||
transactional emails plus marketing features.
|
||||
Most email platforms charge by the contact, lock you in, and split transactional from marketing. Plunk does all three in one open-source platform at $0.001 per email.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -178,7 +177,7 @@ export default function CompetitorsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Compare Plunk with Industry Leaders
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
|
||||
@@ -223,7 +222,7 @@ export default function CompetitorsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -237,11 +236,10 @@ export default function CompetitorsIndex() {
|
||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
||||
}
|
||||
>
|
||||
<Mail className="h-8 w-8 text-blue-500 mb-4" />
|
||||
<Mail className="h-8 w-8 text-neutral-900 mb-4" />
|
||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
|
||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns,
|
||||
automation, and segmentation—all in one platform.
|
||||
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns, automation, and segmentation. All in one platform.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -254,7 +252,7 @@ export default function CompetitorsIndex() {
|
||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
||||
}
|
||||
>
|
||||
<Code className="h-8 w-8 text-green-500 mb-4" />
|
||||
<Code className="h-8 w-8 text-neutral-900 mb-4" />
|
||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
|
||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||
AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and
|
||||
@@ -271,7 +269,7 @@ export default function CompetitorsIndex() {
|
||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
||||
}
|
||||
>
|
||||
<DollarSign className="h-8 w-8 text-yellow-500 mb-4" />
|
||||
<DollarSign className="h-8 w-8 text-neutral-900 mb-4" />
|
||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Simple Pricing</h3>
|
||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||
Pay-as-you-go pricing with all features included. No separate charges for transactional vs marketing
|
||||
@@ -282,7 +280,12 @@ export default function CompetitorsIndex() {
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -290,7 +293,7 @@ export default function CompetitorsIndex() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to try Plunk?</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to try Plunk?</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
|
||||
</p>
|
||||
@@ -300,10 +303,13 @@ export default function CompetitorsIndex() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Klaviyo over Plunk?',
|
||||
answer:
|
||||
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag—perfect for developers and growing businesses.',
|
||||
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag. It is built for developers and growing businesses who want the capabilities without the cost.',
|
||||
},
|
||||
{
|
||||
question: 'How much cheaper is Plunk compared to Klaviyo?',
|
||||
answer:
|
||||
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing—you only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
||||
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing. You only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk handle e-commerce emails?',
|
||||
@@ -58,13 +58,13 @@ export default function KlaviyoComparison() {
|
||||
<NextSeo
|
||||
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
||||
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
||||
canonical="https://next.useplunk.com/vs/klaviyo"
|
||||
canonical="https://www.useplunk.com/vs/klaviyo"
|
||||
openGraph={{
|
||||
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://next.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,15 +94,14 @@ export default function KlaviyoComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Klaviyo</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Klaviyo
|
||||
</h1>
|
||||
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers email automation for any use
|
||||
case—including e-commerce—at a fraction of the cost. No hidden fees, no contact-based pricing.
|
||||
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers the same email automation, including full e-commerce support, at a fraction of the cost. No hidden fees, no contact-based pricing.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -141,7 +140,7 @@ export default function KlaviyoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
E-commerce Email Without the Premium Price
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||
@@ -233,7 +232,7 @@ export default function KlaviyoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
E-commerce Email, Developer-Friendly
|
||||
</h2>
|
||||
</motion.div>
|
||||
@@ -376,7 +375,7 @@ export default function KlaviyoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
|
||||
@@ -386,7 +385,12 @@ export default function KlaviyoComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -394,7 +398,7 @@ export default function KlaviyoComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
E-commerce email at a fraction of the cost
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
@@ -407,10 +411,13 @@ export default function KlaviyoComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -39,7 +39,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Does Plunk have the same modern features as Loops?',
|
||||
answer:
|
||||
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API—just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
||||
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API, just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
||||
},
|
||||
{
|
||||
question: 'How easy is it to migrate from Loops to Plunk?',
|
||||
@@ -57,13 +57,13 @@ export default function LoopsComparison() {
|
||||
<NextSeo
|
||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/loops"
|
||||
canonical="https://www.useplunk.com/vs/loops"
|
||||
openGraph={{
|
||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||
url: 'https://next.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
url: 'https://www.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function LoopsComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Loops</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Loops
|
||||
@@ -140,7 +140,7 @@ export default function LoopsComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Transparent Pricing vs Vendor Lock-In
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
|
||||
@@ -232,7 +232,7 @@ export default function LoopsComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Loops</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Loops</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -358,8 +358,7 @@ export default function LoopsComparison() {
|
||||
</div>
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All Features Included</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||
Transactional emails, campaigns, workflows, segmentation—all included. No artificial feature gating
|
||||
based on your plan.
|
||||
Transactional emails, campaigns, workflows, segmentation. All included. No artificial feature gating based on your plan.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -374,7 +373,7 @@ export default function LoopsComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -385,7 +384,12 @@ export default function LoopsComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -393,7 +397,7 @@ export default function LoopsComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Switch to open source</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Switch to open source</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
|
||||
required.
|
||||
@@ -404,10 +408,13 @@ export default function LoopsComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -58,13 +58,13 @@ export default function MailchimpComparison() {
|
||||
<NextSeo
|
||||
title="Mailchimp Alternative for Developers | Plunk"
|
||||
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
|
||||
canonical="https://next.useplunk.com/vs/mailchimp"
|
||||
canonical="https://www.useplunk.com/vs/mailchimp"
|
||||
openGraph={{
|
||||
title: 'Mailchimp Alternative for Developers | Plunk',
|
||||
description:
|
||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||
url: 'https://next.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function MailchimpComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailchimp</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Mailchimp
|
||||
@@ -141,7 +141,7 @@ export default function MailchimpComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -231,7 +231,7 @@ export default function MailchimpComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Developers Choose Plunk</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Developers Choose Plunk</h2>
|
||||
</motion.div>
|
||||
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
@@ -367,7 +367,7 @@ export default function MailchimpComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
|
||||
@@ -377,7 +377,12 @@ export default function MailchimpComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -385,7 +390,7 @@ export default function MailchimpComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Ready for a better developer experience?
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
@@ -398,10 +403,13 @@ export default function MailchimpComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -30,7 +30,7 @@ const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Is Plunk more expensive than MailerLite?',
|
||||
answer:
|
||||
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability—Plunk only charges for actual usage.',
|
||||
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability: Plunk only charges for actual usage.',
|
||||
},
|
||||
{
|
||||
question: 'Can I self-host Plunk unlike MailerLite?',
|
||||
@@ -58,13 +58,13 @@ export default function MailerliteComparison() {
|
||||
<NextSeo
|
||||
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
|
||||
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
|
||||
canonical="https://next.useplunk.com/vs/mailerlite"
|
||||
canonical="https://www.useplunk.com/vs/mailerlite"
|
||||
openGraph={{
|
||||
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||
url: 'https://next.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function MailerliteComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs MailerLite</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for MailerLite
|
||||
@@ -141,7 +141,7 @@ export default function MailerliteComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Open Source Meets Developer Experience
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
|
||||
@@ -233,7 +233,7 @@ export default function MailerliteComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
The Truly Developer-First Alternative
|
||||
</h2>
|
||||
</motion.div>
|
||||
@@ -376,7 +376,7 @@ export default function MailerliteComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
|
||||
@@ -386,7 +386,12 @@ export default function MailerliteComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -394,7 +399,7 @@ export default function MailerliteComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||
Experience true developer-first email
|
||||
</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
@@ -407,10 +412,13 @@ export default function MailerliteComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function MailgunComparison() {
|
||||
<NextSeo
|
||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||
canonical="https://next.useplunk.com/vs/mailgun"
|
||||
canonical="https://www.useplunk.com/vs/mailgun"
|
||||
openGraph={{
|
||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
url: 'https://www.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -89,15 +89,14 @@ export default function MailgunComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailgun</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Mailgun
|
||||
</h1>
|
||||
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Mailgun is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
||||
open-source.
|
||||
Mailgun only handles transactional email. Plunk adds marketing campaigns, workflow automation, and segmentation on top, all open-source and self-hostable at $0.001 per email.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -136,7 +135,7 @@ export default function MailgunComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -226,7 +225,7 @@ export default function MailgunComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
|
||||
@@ -241,7 +240,7 @@ export default function MailgunComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -376,7 +375,12 @@ export default function MailgunComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -384,7 +388,7 @@ export default function MailgunComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
||||
</p>
|
||||
@@ -394,10 +398,13 @@ export default function MailgunComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
|
||||
@@ -54,13 +54,13 @@ export default function PostmarkComparison() {
|
||||
<NextSeo
|
||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||
canonical="https://next.useplunk.com/vs/postmark"
|
||||
canonical="https://www.useplunk.com/vs/postmark"
|
||||
openGraph={{
|
||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||
description:
|
||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||
url: 'https://next.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
url: 'https://www.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -90,7 +90,7 @@ export default function PostmarkComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Postmark</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Postmark
|
||||
@@ -137,7 +137,7 @@ export default function PostmarkComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>One Platform for All Your Emails</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>One Platform for All Your Emails</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -227,7 +227,7 @@ export default function PostmarkComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Postmark</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Postmark</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function PostmarkComparison() {
|
||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||
Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads,
|
||||
re-engage customers—all automated.
|
||||
re-engage customers. All automated.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -369,7 +369,7 @@ export default function PostmarkComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -380,7 +380,12 @@ export default function PostmarkComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -388,7 +393,7 @@ export default function PostmarkComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get more from your email platform</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get more from your email platform</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
|
||||
free.
|
||||
@@ -399,10 +404,13 @@ export default function PostmarkComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -53,13 +53,13 @@ export default function ResendComparison() {
|
||||
<NextSeo
|
||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||
canonical="https://next.useplunk.com/vs/resend"
|
||||
canonical="https://www.useplunk.com/vs/resend"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||
description:
|
||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||
url: 'https://next.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
url: 'https://www.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -89,15 +89,14 @@ export default function ResendComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Resend</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Resend
|
||||
</h1>
|
||||
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||
Resend is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
||||
open-source. Choose based on what you need.
|
||||
Resend is transactional-only. Plunk gives you transactional emails, marketing campaigns, and workflow automation in a single open-source platform. No second tool, no second bill.
|
||||
</p>
|
||||
|
||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||
@@ -136,7 +135,7 @@ export default function ResendComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -226,7 +225,7 @@ export default function ResendComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||
</motion.div>
|
||||
|
||||
<ComparisonTable competitorName="Resend" rows={comparisonData} />
|
||||
@@ -241,7 +240,7 @@ export default function ResendComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -376,7 +375,12 @@ export default function ResendComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -384,7 +388,7 @@ export default function ResendComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
||||
</p>
|
||||
@@ -394,10 +398,13 @@ export default function ResendComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href={WIKI_URI}
|
||||
|
||||
@@ -57,13 +57,13 @@ export default function SendGridComparison() {
|
||||
<NextSeo
|
||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||
canonical="https://next.useplunk.com/vs/sendgrid"
|
||||
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||
openGraph={{
|
||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||
url: 'https://next.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function SendGridComparison() {
|
||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs SendGrid</span>
|
||||
</div>
|
||||
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for SendGrid
|
||||
@@ -140,7 +140,7 @@ export default function SendGridComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -230,7 +230,7 @@ export default function SendGridComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-20 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over SendGrid</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over SendGrid</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -372,7 +372,7 @@ export default function SendGridComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mb-16 text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -383,7 +383,12 @@ export default function SendGridComparison() {
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'border-t border-neutral-200 py-32'}>
|
||||
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
|
||||
<div
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
|
||||
}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
@@ -391,7 +396,7 @@ export default function SendGridComparison() {
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl text-center'}
|
||||
>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch today</h2>
|
||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch today</h2>
|
||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
|
||||
</p>
|
||||
@@ -401,10 +406,13 @@ export default function SendGridComparison() {
|
||||
whileTap={{scale: 0.98}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Start free trial
|
||||
<span className={'flex items-center gap-2'}>
|
||||
Get started free
|
||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</span>
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/pricing"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@plunk/db": "*",
|
||||
"dotenv": "^17.2.3",
|
||||
"mailparser": "^3.7.1",
|
||||
"mailparser": "^3.9.3",
|
||||
"signale": "^1.4.0",
|
||||
"smtp-server": "^3.13.4"
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {Contact} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
||||
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface ContactPickerProps {
|
||||
/** Currently selected emails */
|
||||
selected: string[];
|
||||
/** Called when selection changes */
|
||||
onChange: (emails: string[]) => void;
|
||||
/** Emails already in the segment (shown as disabled) */
|
||||
existing?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-select contact picker backed by the /contacts API.
|
||||
* Only fetches when the user types (safe for large contact lists).
|
||||
*/
|
||||
export function ContactPicker({
|
||||
selected,
|
||||
onChange,
|
||||
existing = [],
|
||||
placeholder = 'Search contacts...',
|
||||
}: ContactPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
// Only fetch when there's a search term — avoids loading all contacts on open
|
||||
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const contacts = data?.data ?? [];
|
||||
|
||||
const toggle = (email: string) => {
|
||||
if (selected.includes(email)) {
|
||||
onChange(selected.filter(e => e !== email));
|
||||
} else {
|
||||
onChange([...selected, email]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
align="start"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Type an email to search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||
{debouncedSearch.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||
) : contacts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||
) : (
|
||||
contacts.map(contact => {
|
||||
const isSelected = selected.includes(contact.email);
|
||||
const isExisting = existing.includes(contact.email);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
disabled={isExisting}
|
||||
onClick={() => toggle(contact.email)}
|
||||
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||
>
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||
{isExisting && (
|
||||
<span className="text-xs text-neutral-400 shrink-0">already member</span>
|
||||
)}
|
||||
{isSelected && !isExisting && (
|
||||
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -74,6 +74,11 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||
const mobileUserMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort projects alphabetically by name
|
||||
const sortedProjects = useMemo(() => {
|
||||
return [...availableProjects].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [availableProjects]);
|
||||
|
||||
// Handle click outside for project menu
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -179,8 +184,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
|
||||
{/* Project Dropdown */}
|
||||
{showProjectMenu && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
||||
{availableProjects.map(project => (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
|
||||
{sortedProjects.map(project => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={e => {
|
||||
@@ -189,14 +194,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
|
||||
>
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||
{activeProject?.id === project.id && (
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
@@ -204,7 +209,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<Link
|
||||
href="/projects/create"
|
||||
onClick={() => setShowProjectMenu(false)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@plunk/ui';
|
||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||
import {network} from '../../lib/network';
|
||||
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
|
||||
interface EmailEditorProps {
|
||||
@@ -51,54 +52,6 @@ const commonVariables = [
|
||||
{name: 'manageUrl', description: 'Manage link'},
|
||||
];
|
||||
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
|
||||
// Detect if initial value has custom HTML and start in appropriate mode
|
||||
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
|
||||
@@ -348,31 +301,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
|
||||
if (iframeDoc) {
|
||||
const previewContent = getPreviewHtml();
|
||||
const fullHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #374151;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${previewContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const fullHtml = wrapEmailWithStyles(previewContent);
|
||||
|
||||
iframeDoc.open();
|
||||
iframeDoc.write(fullHtml);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
|
||||
import {Monitor, Smartphone, Tablet} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import {wrapEmailWithStyles} from '../lib/emailStyles';
|
||||
|
||||
interface EmailPreviewModalProps {
|
||||
open: boolean;
|
||||
@@ -16,391 +17,6 @@ interface EmailPreviewModalProps {
|
||||
|
||||
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
// Check for common patterns that indicate custom HTML
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
// Check for custom classes (exclude TipTap's generated classes)
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
// Split by whitespace to get individual classes
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
// Check if any class is NOT in the allowed list
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to wrap email body with necessary styles for proper rendering
|
||||
// Only wraps visual editor content - custom HTML is returned as-is
|
||||
const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
// If this is custom HTML (from HTML editor), return it as-is without wrapping
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
// For visual editor content, wrap with prose styles
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 65ch;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border-color: #e5e7eb;
|
||||
border-top-width: 1px;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
export function EmailPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||
import {BookOpen, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {BookOpen, Check, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
@@ -23,6 +24,14 @@ interface QuickStartProps {
|
||||
|
||||
// Help resources that always appear
|
||||
function HelpResources() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyEmail = () => {
|
||||
void navigator.clipboard.writeText('support@useplunk.com');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||
@@ -39,6 +48,39 @@ function HelpResources() {
|
||||
Join Discord
|
||||
</Button>
|
||||
</Link>
|
||||
<motion.button
|
||||
onClick={copyEmail}
|
||||
whileTap={{scale: 0.97}}
|
||||
className="flex-1 relative flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
|
||||
>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.span
|
||||
key="copied"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600">Copied!</span>
|
||||
</motion.span>
|
||||
) : (
|
||||
<motion.span
|
||||
key="idle"
|
||||
className="flex items-center gap-1.5"
|
||||
initial={{opacity: 0, y: 6}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: -6}}
|
||||
transition={{duration: 0.15}}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5" />
|
||||
Email support
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -91,6 +91,34 @@ const STEP_TYPE_BG = {
|
||||
UPDATE_CONTACT: '#e0e7ff',
|
||||
};
|
||||
|
||||
// Multi-branch condition helpers
|
||||
function getExpectedBranches(config: any): string[] {
|
||||
if (config?.mode === 'multi') {
|
||||
return [...(config.branches || []).map((b: any) => b.id), 'default'];
|
||||
}
|
||||
return ['yes', 'no'];
|
||||
}
|
||||
|
||||
function getBranchLabel(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return 'Default';
|
||||
const branch = config.branches?.find((b: any) => b.id === branchId);
|
||||
return branch?.name || branchId;
|
||||
}
|
||||
return branchId === 'yes' ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
const BRANCH_COLORS = ['#16a34a', '#dc2626', '#2563eb', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#059669'];
|
||||
|
||||
function getBranchColor(config: any, branchId: string): string {
|
||||
if (config?.mode === 'multi') {
|
||||
if (branchId === 'default') return '#64748b';
|
||||
const idx = config.branches?.findIndex((b: any) => b.id === branchId) ?? 0;
|
||||
return BRANCH_COLORS[idx % BRANCH_COLORS.length]!;
|
||||
}
|
||||
return branchId === 'yes' ? '#16a34a' : '#dc2626';
|
||||
}
|
||||
|
||||
// Dagre layout function
|
||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
@@ -129,6 +157,7 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
});
|
||||
|
||||
// Center "Add Step" nodes directly below their parent nodes
|
||||
// For condition nodes with multiple branches, let dagre handle horizontal spread
|
||||
const adjustedNodes = layoutedNodes.map(node => {
|
||||
if (node.type === 'addStep') {
|
||||
// Find the parent node (the source of the edge connecting to this add node)
|
||||
@@ -136,7 +165,17 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||
if (parentEdge) {
|
||||
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||
if (parentNode) {
|
||||
// Center the add node below the parent node
|
||||
// Check how many add-step siblings share this parent
|
||||
const siblingAddNodes = edges.filter(
|
||||
e => e.source === parentEdge.source && layoutedNodes.find(n => n.id === e.target && n.type === 'addStep'),
|
||||
);
|
||||
|
||||
if (siblingAddNodes.length > 1) {
|
||||
// Multiple branches — let dagre's spread positioning stand, only keep y
|
||||
return node;
|
||||
}
|
||||
|
||||
// Single add node — center below parent
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
@@ -324,9 +363,16 @@ function CustomNode({
|
||||
: String(data.config.field)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
{data.config.mode === 'multi' ? (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} +
|
||||
default
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-neutral-500 ml-4">
|
||||
{data.config.operator} "{String(data.config.value)}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -393,7 +439,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const [addStepContext, setAddStepContext] = useState<{
|
||||
fromStepId: string | null;
|
||||
branch?: 'yes' | 'no';
|
||||
branch?: string;
|
||||
} | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||
@@ -455,41 +501,27 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
// Check for yes and no branches
|
||||
const hasYesBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
});
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-yes`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'Yes',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||
},
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
}
|
||||
|
||||
if (!hasNoBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-no`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: 'No',
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
nodes.push({
|
||||
id: `${step.id}-add-${branchId}`,
|
||||
type: 'addStep',
|
||||
position: {x: 0, y: 0},
|
||||
draggable: false,
|
||||
data: {
|
||||
label: getBranchLabel(step.config, branchId),
|
||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: branchId}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For non-condition steps, add + node if no outgoing transitions
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
@@ -520,7 +552,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const condition = transition.condition;
|
||||
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
||||
const branch =
|
||||
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
|
||||
condition && typeof condition === 'object' && 'branch' in condition
|
||||
? (condition.branch as string)
|
||||
: undefined;
|
||||
|
||||
const branchColor = branch ? getBranchColor(step.config, branch) : '#94a3b8';
|
||||
const branchLabel = branch ? getBranchLabel(step.config, branch) : undefined;
|
||||
|
||||
edges.push({
|
||||
id: transition.id,
|
||||
@@ -528,9 +565,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
target: transition.toStepId,
|
||||
type: 'smoothstep',
|
||||
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
||||
label: isConditional ? (branch === 'yes' ? 'Yes' : 'No') : undefined,
|
||||
label: isConditional ? branchLabel : undefined,
|
||||
labelStyle: {
|
||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
||||
fill: isConditional ? branchColor : '#64748b',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
},
|
||||
@@ -541,12 +578,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {
|
||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
stroke: isConditional ? branchColor : '#94a3b8',
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
||||
color: isConditional ? branchColor : '#94a3b8',
|
||||
width: 22,
|
||||
height: 22,
|
||||
},
|
||||
@@ -558,47 +595,37 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
if (step.type === 'EXIT') return;
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
const hasYesBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
||||
);
|
||||
const hasNoBranch = step.outgoingTransitions?.some(
|
||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
||||
);
|
||||
const expectedBranches = getExpectedBranches(step.config);
|
||||
|
||||
if (!hasYesBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-yes-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-yes`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'Yes',
|
||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
expectedBranches.forEach(branchId => {
|
||||
const hasBranch = step.outgoingTransitions?.some(
|
||||
t =>
|
||||
t.condition &&
|
||||
typeof t.condition === 'object' &&
|
||||
'branch' in t.condition &&
|
||||
t.condition.branch === branchId,
|
||||
);
|
||||
|
||||
if (!hasNoBranch) {
|
||||
edges.push({
|
||||
id: `${step.id}-add-no-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-no`,
|
||||
type: 'straight',
|
||||
animated: false,
|
||||
label: 'No',
|
||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
if (!hasBranch) {
|
||||
const color = getBranchColor(step.config, branchId);
|
||||
const label = getBranchLabel(step.config, branchId);
|
||||
|
||||
edges.push({
|
||||
id: `${step.id}-add-${branchId}-edge`,
|
||||
source: step.id,
|
||||
target: `${step.id}-add-${branchId}`,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
label,
|
||||
labelStyle: {fill: color, fontWeight: 600, fontSize: 12},
|
||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||
labelBgPadding: [8, 4] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||
edges.push({
|
||||
@@ -645,15 +672,13 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
const connectedAddNodes = updatedNodes.filter(node => {
|
||||
if (node.type !== 'addStep') return false;
|
||||
// Check if this add node is connected to the moved node
|
||||
return (
|
||||
node.id === `${movedNode.id}-add` ||
|
||||
node.id === `${movedNode.id}-add-yes` ||
|
||||
node.id === `${movedNode.id}-add-no`
|
||||
);
|
||||
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
|
||||
});
|
||||
|
||||
// Update positions of connected "+" nodes to stay centered below parent
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
// Update positions of connected "+" nodes to stay below parent
|
||||
if (connectedAddNodes.length === 1) {
|
||||
// Single add node — center below parent
|
||||
const addNode = connectedAddNodes[0]!;
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
@@ -665,7 +690,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (connectedAddNodes.length > 1) {
|
||||
// Multiple branches — move all add nodes by the same delta as the parent
|
||||
const oldX = movedNode.position.x;
|
||||
const deltaX = change.position.x - oldX;
|
||||
connectedAddNodes.forEach(addNode => {
|
||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||
const existingNode = updatedNodes[addNodeIndex];
|
||||
updatedNodes[addNodeIndex] = {
|
||||
...existingNode,
|
||||
position: {
|
||||
x: existingNode.position.x + deltaX,
|
||||
y: existingNode.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -733,7 +775,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
||||
|
||||
// Create the transition with proper condition
|
||||
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
||||
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
|
||||
const expectedBranches = fromStep.type === 'CONDITION' ? getExpectedBranches(fromStep.config) : [];
|
||||
const branchIndex = addStepContext.branch ? expectedBranches.indexOf(addStepContext.branch) : -1;
|
||||
const priority = branchIndex >= 0 ? branchIndex : 0;
|
||||
|
||||
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
||||
'POST',
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
||||
export const detectCustomHtmlPatterns = (html: string): boolean => {
|
||||
if (!html || html.trim() === '') return false;
|
||||
|
||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||
|
||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||
let hasCustomClasses = false;
|
||||
for (const match of classMatches) {
|
||||
const classValue = match[1];
|
||||
if (!classValue) continue;
|
||||
|
||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
||||
const allowedPrefixes = [
|
||||
'prose',
|
||||
'variable-',
|
||||
'email-image',
|
||||
'ProseMirror',
|
||||
'resizable-image',
|
||||
'selected',
|
||||
'resize-handle',
|
||||
];
|
||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
||||
if (hasDisallowedClass) {
|
||||
hasCustomClasses = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||
const hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
};
|
||||
|
||||
export const wrapEmailWithStyles = (htmlBody: string): string => {
|
||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
/* Base reset */
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* Tailwind Typography (prose) base styles */
|
||||
.prose {
|
||||
color: #374151;
|
||||
max-width: 600px;
|
||||
}
|
||||
.prose [class~="lead"] {
|
||||
color: #4b5563;
|
||||
font-size: 1.25em;
|
||||
line-height: 1.6;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 1.2em;
|
||||
}
|
||||
.prose a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose strong {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ol, .prose ul {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
padding-left: 1.625em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose ol > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose ul > li {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
.prose > ul > li p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 1.25em;
|
||||
}
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 3em;
|
||||
margin-bottom: 3em;
|
||||
}
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #111827;
|
||||
border-left-width: 0.25rem;
|
||||
border-left-color: #e5e7eb;
|
||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 1.6em;
|
||||
padding-left: 1em;
|
||||
}
|
||||
.prose h1 {
|
||||
color: #111827;
|
||||
font-weight: 800;
|
||||
font-size: 2.25em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8888889em;
|
||||
line-height: 1.1111111;
|
||||
}
|
||||
.prose h2 {
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
font-size: 1.5em;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 1em;
|
||||
line-height: 1.3333333;
|
||||
}
|
||||
.prose h3 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 1.25em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.6em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.prose h4 {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.prose img {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure {
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose code {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
.prose pre {
|
||||
color: #e5e7eb;
|
||||
background-color: #1f2937;
|
||||
overflow-x: auto;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
border-radius: 0.375rem;
|
||||
padding-top: 0.8571429em;
|
||||
padding-right: 1.1428571em;
|
||||
padding-bottom: 0.8571429em;
|
||||
padding-left: 1.1428571em;
|
||||
}
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
.prose pre code::before {
|
||||
content: none;
|
||||
}
|
||||
.prose pre code::after {
|
||||
content: none;
|
||||
}
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
text-align: left;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 2em;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.7142857;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #d1d5db;
|
||||
}
|
||||
.prose thead th {
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
vertical-align: bottom;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #e5e7eb;
|
||||
}
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 0.5714286em;
|
||||
padding-right: 0.5714286em;
|
||||
padding-bottom: 0.5714286em;
|
||||
padding-left: 0.5714286em;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
/* prose-sm modifier */
|
||||
.prose-sm {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
.prose-sm p {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
}
|
||||
.prose-sm h1 {
|
||||
font-size: 2.1428571em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.prose-sm h2 {
|
||||
font-size: 1.4285714em;
|
||||
margin-top: 1.6em;
|
||||
margin-bottom: 0.8em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.prose-sm h3 {
|
||||
font-size: 1.2857143em;
|
||||
margin-top: 1.5555556em;
|
||||
margin-bottom: 0.4444444em;
|
||||
line-height: 1.5555556;
|
||||
}
|
||||
.prose-sm h4 {
|
||||
margin-top: 1.4285714em;
|
||||
margin-bottom: 0.5714286em;
|
||||
line-height: 1.4285714;
|
||||
}
|
||||
.prose-sm img {
|
||||
margin-top: 1.7142857em;
|
||||
margin-bottom: 1.7142857em;
|
||||
}
|
||||
.prose-sm ol, .prose-sm ul {
|
||||
margin-top: 1.1428571em;
|
||||
margin-bottom: 1.1428571em;
|
||||
padding-left: 1.5714286em;
|
||||
}
|
||||
.prose-sm li {
|
||||
margin-top: 0.2857143em;
|
||||
margin-bottom: 0.2857143em;
|
||||
}
|
||||
|
||||
/* max-w-none utility */
|
||||
.max-w-none {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Custom editor styles */
|
||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose th, .prose td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.prose th {
|
||||
background-color: #f3f4f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-wrapper {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.prose .resizable-image-container img {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {network} from '../lib/network';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
import Script from 'next/script';
|
||||
|
||||
// Configure dayjs plugins globally
|
||||
dayjs.extend(relativeTime);
|
||||
@@ -112,6 +113,14 @@ export default function WithProviders(props: AppProps) {
|
||||
}}
|
||||
>
|
||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.driaug.com/script.js"
|
||||
data-website-id="5880df93-9025-41ae-8e33-7c3da865f764"
|
||||
data-domains="next-app.useplunk.com"
|
||||
/>
|
||||
|
||||
<ActiveProjectProvider>
|
||||
<Root {...props} />
|
||||
</ActiveProjectProvider>
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
||||
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import {ContactPicker} from '../../components/ContactPicker';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
type SegmentWithType = Segment & {type: SegmentType};
|
||||
|
||||
// Count total filters in a condition (recursive)
|
||||
function countFilters(condition: FilterCondition): number {
|
||||
let count = 0;
|
||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
||||
const router = useRouter();
|
||||
const {id} = router.query;
|
||||
|
||||
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
|
||||
const {data: segment, mutate, isLoading} = useSWR<SegmentWithType>(id ? `/segments/${id}` : null);
|
||||
const [contactsPage, setContactsPage] = useState(1);
|
||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
||||
);
|
||||
const {
|
||||
data: contactsData,
|
||||
isLoading: isLoadingContacts,
|
||||
mutate: mutateContacts,
|
||||
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
@@ -57,7 +63,12 @@ export default function SegmentDetailPage() {
|
||||
const [isComputing, setIsComputing] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Static segment member management
|
||||
const [pickedEmails, setPickedEmails] = useState<string[]>([]);
|
||||
const [isAddingMembers, setIsAddingMembers] = useState(false);
|
||||
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (segment) {
|
||||
setName(segment.name);
|
||||
setDescription(segment.description || '');
|
||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
...(segment?.type !== 'STATIC' && {condition}),
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment updated successfully');
|
||||
@@ -112,6 +123,47 @@ export default function SegmentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMembers = async () => {
|
||||
if (pickedEmails.length === 0) {
|
||||
toast.error('Select at least one contact');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingMembers(true);
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${id}/members`,
|
||||
{emails: pickedEmails},
|
||||
);
|
||||
|
||||
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
|
||||
setPickedEmails([]);
|
||||
void mutate();
|
||||
void mutateContacts();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to add contacts');
|
||||
} finally {
|
||||
setIsAddingMembers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (email: string) => {
|
||||
setRemovingEmail(email);
|
||||
try {
|
||||
await network.fetch<{removed: number}, typeof SegmentSchemas.members>('DELETE', `/segments/${id}/members`, {
|
||||
emails: [email],
|
||||
});
|
||||
toast.success(`Removed ${email} from segment`);
|
||||
void mutate();
|
||||
void mutateContacts();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to remove contact');
|
||||
} finally {
|
||||
setRemovingEmail(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await network.fetch('DELETE', `/segments/${id}`);
|
||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isStatic = segment.type === 'STATIC';
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className="space-y-6">
|
||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||
<p className="text-neutral-500 mt-1">{segment.description}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
isStatic ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isStatic ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && <p className="text-neutral-500 mt-1">{segment.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Filter Builder (DYNAMIC only) */}
|
||||
{!isStatic && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Static member management */}
|
||||
{isStatic && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add Members</CardTitle>
|
||||
<CardDescription>Search and select contacts to add to this segment</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ContactPicker
|
||||
selected={pickedEmails}
|
||||
onChange={setPickedEmails}
|
||||
existing={contactsData?.data.map(c => c.email) ?? []}
|
||||
placeholder="Search contacts to add..."
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMembers}
|
||||
disabled={isAddingMembers || pickedEmails.length === 0}
|
||||
>
|
||||
{isAddingMembers
|
||||
? 'Adding...'
|
||||
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contacts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Matching Contacts</CardTitle>
|
||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
||||
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||
<CardDescription>
|
||||
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{trackMembership && (
|
||||
{!isStatic && trackMembership && (
|
||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||
{isComputing ? 'Computing...' : 'Recompute'}
|
||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
||||
) : contactsData?.data.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<p className="text-neutral-500">No contacts match this segment</p>
|
||||
<p className="text-neutral-500">
|
||||
{isStatic ? 'No members in this segment yet' : 'No contacts match this segment'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
||||
)}
|
||||
<span className="text-sm font-medium">{contact.email}</span>
|
||||
</div>
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/contacts/${contact.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
{isStatic && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMember(contact.email)}
|
||||
disabled={removingEmail === contact.email}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -355,24 +464,28 @@ export default function SegmentDetailPage() {
|
||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Groups</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{!isStatic && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">Groups</span>
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
|
||||
</span>
|
||||
</div>
|
||||
{segment.description && (
|
||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||
)}
|
||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
||||
<span className="text-sm text-neutral-600">Filters</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{countFiltersInCondition(segment.condition)}
|
||||
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||
? '—'
|
||||
: countFiltersInCondition(segment.condition)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||
import {ContactPicker} from '../../components/ContactPicker';
|
||||
import {network} from '../../lib/network';
|
||||
import {ArrowLeft, Save} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
||||
import type {Segment} from '@plunk/db';
|
||||
import {SegmentSchemas} from '@plunk/shared';
|
||||
|
||||
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||
|
||||
export default function NewSegmentPage() {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||
const [trackMembership, setTrackMembership] = useState(false);
|
||||
const [condition, setCondition] = useState<FilterCondition>({
|
||||
logic: 'AND',
|
||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
||||
},
|
||||
],
|
||||
});
|
||||
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||
name,
|
||||
description: description || undefined,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
toast.success('Segment created successfully');
|
||||
|
||||
// For static segments with pre-selected contacts, add them now
|
||||
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
||||
try {
|
||||
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||
'POST',
|
||||
`/segments/${segment.id}/members`,
|
||||
{emails: selectedContacts},
|
||||
);
|
||||
toast.success(
|
||||
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
|
||||
);
|
||||
} catch {
|
||||
// Segment was created; just warn about members
|
||||
toast.warning('Segment created, but some contacts could not be added');
|
||||
}
|
||||
} else {
|
||||
toast.success('Segment created successfully');
|
||||
}
|
||||
|
||||
void router.push('/segments');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
||||
<p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
|
||||
<p className="text-neutral-500 mt-1">
|
||||
{segmentType === 'DYNAMIC'
|
||||
? 'Build complex audience filters with AND/OR logic'
|
||||
: 'Manually curate a list of contacts'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type Toggle */}
|
||||
<div className="flex gap-2 p-1 bg-neutral-100 rounded-lg w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('DYNAMIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'DYNAMIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Dynamic
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSegmentType('STATIC')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
segmentType === 'STATIC'
|
||||
? 'bg-white text-neutral-900 shadow-sm'
|
||||
: 'text-neutral-600 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
Static
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Filter Builder */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Filter Builder or Contact Picker */}
|
||||
{segmentType === 'DYNAMIC' ? (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Initial Members</CardTitle>
|
||||
<CardDescription>
|
||||
Optionally add contacts now — you can always add or remove members later
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ContactPicker
|
||||
selected={selectedContacts}
|
||||
onChange={setSelectedContacts}
|
||||
placeholder="Search and select contacts..."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -205,23 +205,33 @@ export default function WorkflowEditorPage() {
|
||||
break;
|
||||
|
||||
case 'CONDITION':
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
if (config.mode === 'multi') {
|
||||
// Multi-branch validation
|
||||
if (!config.field) {
|
||||
errors.push(`"${step.name}" step is missing condition field`);
|
||||
}
|
||||
if (!Array.isArray(config.branches) || config.branches.length === 0) {
|
||||
errors.push(`"${step.name}" step needs at least one branch`);
|
||||
}
|
||||
} else {
|
||||
// Extract field name from both legacy format (object) and new format (string)
|
||||
let fieldValue = '';
|
||||
if (config.field) {
|
||||
if (typeof config.field === 'object' && config.field !== null && 'field' in config.field) {
|
||||
fieldValue = String(config.field.field || '');
|
||||
} else {
|
||||
fieldValue = String(config.field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
if (!fieldValue || !config.operator) {
|
||||
errors.push(`"${step.name}" step is missing condition configuration (field or operator)`);
|
||||
}
|
||||
// Check if value is required for this operator
|
||||
const operatorNeedsValue = !['exists', 'notExists'].includes(String(config.operator || ''));
|
||||
if (operatorNeedsValue && (config.value === undefined || config.value === null || config.value === '')) {
|
||||
errors.push(`"${step.name}" step is missing a value for the condition`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -264,20 +274,37 @@ export default function WorkflowEditorPage() {
|
||||
errors.push('Workflow must have a trigger step');
|
||||
}
|
||||
|
||||
// Check for CONDITION steps that don't have both yes and no branches
|
||||
// Check for CONDITION steps that don't have all required branches connected
|
||||
workflow.steps.forEach(step => {
|
||||
if (step.type === 'CONDITION' && step.outgoingTransitions) {
|
||||
const hasYesBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
||||
});
|
||||
const hasNoBranch = step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
||||
const config = step.config && typeof step.config === 'object' && !Array.isArray(step.config) ? step.config : {};
|
||||
|
||||
// Determine expected branches based on mode
|
||||
let expectedBranches: string[];
|
||||
if ((config as any).mode === 'multi' && Array.isArray((config as any).branches)) {
|
||||
expectedBranches = [...(config as any).branches.map((b: any) => b.id), 'default'];
|
||||
} else {
|
||||
expectedBranches = ['yes', 'no'];
|
||||
}
|
||||
|
||||
const missingBranches = expectedBranches.filter(branchId => {
|
||||
return !step.outgoingTransitions.some(t => {
|
||||
const condition = t.condition;
|
||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||
});
|
||||
});
|
||||
|
||||
if (!hasYesBranch || !hasNoBranch) {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
if (missingBranches.length > 0) {
|
||||
if ((config as any).mode === 'multi') {
|
||||
const branchNames = missingBranches.map(id => {
|
||||
if (id === 'default') return 'Default';
|
||||
const branch = (config as any).branches?.find((b: any) => b.id === id);
|
||||
return branch?.name || id;
|
||||
});
|
||||
errors.push(`"${step.name}" condition step is missing connections for: ${branchNames.join(', ')}`);
|
||||
} else {
|
||||
errors.push(`"${step.name}" condition step must have both YES and NO branches connected`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1839,6 +1866,59 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
);
|
||||
|
||||
// CONDITION fields - handle both old format (object) and new format (string)
|
||||
const [conditionMode, setConditionMode] = useState<'binary' | 'multi'>(() => {
|
||||
return config?.mode === 'multi' ? 'multi' : 'binary';
|
||||
});
|
||||
|
||||
// Helper to check if switching from multi to binary is safe
|
||||
const hasMultiBranchConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode !== 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has a branch that's not 'yes' or 'no' (multi-branch specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
// Multi-branch specific branches (not the simple if/else branches)
|
||||
return branch !== 'yes' && branch !== 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to check if switching from binary to multi is safe
|
||||
const hasBinaryConnections = () => {
|
||||
if (step.type !== 'CONDITION') return false;
|
||||
if (config?.mode === 'multi') return false;
|
||||
|
||||
const transitions = (step as any).outgoingTransitions || [];
|
||||
// Check if any transition has 'yes' or 'no' branches (binary-specific)
|
||||
return transitions.some((t: any) => {
|
||||
const condition = t.condition;
|
||||
if (condition && typeof condition === 'object' && 'branch' in condition) {
|
||||
const branch = condition.branch as string;
|
||||
return branch === 'yes' || branch === 'no';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
// Safe mode change handler
|
||||
const handleModeChange = (newMode: 'binary' | 'multi') => {
|
||||
// If switching from multi to binary, check for multi-branch connections
|
||||
if (conditionMode === 'multi' && newMode === 'binary' && hasMultiBranchConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
// If switching from binary to multi, check for binary connections
|
||||
if (conditionMode === 'binary' && newMode === 'multi' && hasBinaryConnections()) {
|
||||
// Don't allow the switch - user needs to disconnect branches first
|
||||
return;
|
||||
}
|
||||
setConditionMode(newMode);
|
||||
};
|
||||
const [conditionField, setConditionField] = useState(() => {
|
||||
if (!config?.field) return '';
|
||||
// Handle case where field is an object like {field: 'email', type: 'string'} (legacy format)
|
||||
@@ -1850,6 +1930,19 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
});
|
||||
const [conditionOperator, setConditionOperator] = useState(String(config?.operator || 'equals'));
|
||||
const [conditionValue, setConditionValue] = useState(String(config?.value ?? ''));
|
||||
const [conditionBranches, setConditionBranches] = useState<
|
||||
Array<{id: string; name: string; operator: string; value: string}>
|
||||
>(() => {
|
||||
if (config?.mode === 'multi' && Array.isArray(config.branches)) {
|
||||
return config.branches.map((b: any) => ({
|
||||
id: String(b.id || crypto.randomUUID().slice(0, 8)),
|
||||
name: String(b.name || ''),
|
||||
operator: String(b.operator || 'equals'),
|
||||
value: String(b.value ?? ''),
|
||||
}));
|
||||
}
|
||||
return [{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''}];
|
||||
});
|
||||
const [availableFields, setAvailableFields] = useState<Array<{field: string; type: string; category: string}>>([]);
|
||||
const [loadingFields, setLoadingFields] = useState(false);
|
||||
|
||||
@@ -2020,17 +2113,50 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
}
|
||||
newConfig = {amount, unit: delayUnit};
|
||||
} else if (step.type === 'CONDITION') {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
if (conditionMode === 'multi') {
|
||||
// Validate branches
|
||||
const validBranches = conditionBranches.filter(b => b.name.trim());
|
||||
if (validBranches.length === 0) {
|
||||
toast.error('At least one branch with a name is required');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!conditionField) {
|
||||
toast.error('Please select a field');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
newConfig = {
|
||||
mode: 'multi' as const,
|
||||
field: conditionField,
|
||||
branches: validBranches.map(b => {
|
||||
let parsedValue: string | number | boolean = b.value;
|
||||
if (b.value === 'true') parsedValue = true;
|
||||
else if (b.value === 'false') parsedValue = false;
|
||||
else if (b.value !== '' && !isNaN(Number(b.value))) parsedValue = Number(b.value);
|
||||
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name.trim(),
|
||||
operator: b.operator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// Parse the value based on type
|
||||
let parsedValue: string | number | boolean = conditionValue;
|
||||
if (conditionValue === 'true') parsedValue = true;
|
||||
else if (conditionValue === 'false') parsedValue = false;
|
||||
else if (!isNaN(Number(conditionValue))) parsedValue = Number(conditionValue);
|
||||
|
||||
newConfig = {
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: parsedValue,
|
||||
};
|
||||
}
|
||||
} else if (step.type === 'EXIT') {
|
||||
newConfig = {reason: exitReason};
|
||||
} else if (step.type === 'WEBHOOK') {
|
||||
@@ -2318,10 +2444,71 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pl-3">
|
||||
<p className="text-xs text-neutral-600">
|
||||
Define the condition that will be evaluated to determine which path the workflow should follow
|
||||
</p>
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('binary')}
|
||||
disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'binary'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'multi' && hasMultiBranchConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Simple (If/Else)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleModeChange('multi')}
|
||||
disabled={conditionMode === 'binary' && hasBinaryConnections()}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
conditionMode === 'multi'
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: conditionMode === 'binary' && hasBinaryConnections()
|
||||
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
Multi-branch (Switch)
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
{conditionMode === 'binary'
|
||||
? 'Evaluates a single condition with Yes/No paths'
|
||||
: 'Match a field against multiple values, each routing to its own branch'}
|
||||
</p>
|
||||
{hasMultiBranchConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to simple mode: branches have connected nodes. Disconnect all branch connections
|
||||
first.
|
||||
</div>
|
||||
)}
|
||||
{hasBinaryConnections() && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Cannot switch to multi-branch mode: Yes/No branches have connected nodes. Disconnect all branch
|
||||
connections first.
|
||||
</div>
|
||||
)}
|
||||
{conditionMode !== (config?.mode === 'multi' ? 'multi' : 'binary') &&
|
||||
!hasMultiBranchConnections() &&
|
||||
!hasBinaryConnections() &&
|
||||
(step as any).outgoingTransitions &&
|
||||
(step as any).outgoingTransitions.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-800">
|
||||
<AlertTriangle className="h-3 w-3 inline mr-1" />
|
||||
Changing mode will disconnect existing branches. You will need to rewire them.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field selector (shared between modes) */}
|
||||
<div>
|
||||
<Label htmlFor="editConditionField">Field to Check *</Label>
|
||||
{loadingFields ? (
|
||||
@@ -2396,80 +2583,199 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
{/* Binary mode: single operator + value */}
|
||||
{conditionMode === 'binary' && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="editConditionOperator">Operator *</Label>
|
||||
<Select value={conditionOperator} onValueChange={setConditionOperator}>
|
||||
<SelectTrigger id="editConditionOperator" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
{currentFieldType && (
|
||||
<p className="text-xs text-neutral-500 mt-1.5">
|
||||
Showing operators for{' '}
|
||||
<span className="font-mono bg-neutral-200 px-1 rounded">{currentFieldType}</span> fields
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{needsValue && (
|
||||
<div>
|
||||
<Label htmlFor="editConditionValue">Value *</Label>
|
||||
{currentFieldType === 'boolean' ? (
|
||||
<Select value={conditionValue || 'true'} onValueChange={setConditionValue}>
|
||||
<SelectTrigger id="editConditionValue" className="mt-1.5">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">True</SelectItem>
|
||||
<SelectItem value="false">False</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : currentFieldType === 'number' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="number"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., 100"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter a numeric value to compare against</p>
|
||||
</>
|
||||
) : currentFieldType === 'date' ? (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="datetime-local"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Select a date and time to compare against</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
id="editConditionValue"
|
||||
type="text"
|
||||
value={conditionValue}
|
||||
onChange={e => setConditionValue(e.target.value)}
|
||||
required
|
||||
placeholder="e.g., premium, active"
|
||||
className="mt-1.5"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1.5">Enter the text value to compare against</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Multi-branch mode: dynamic list of branches */}
|
||||
{conditionMode === 'multi' && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Branches</Label>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Each branch defines a condition. The first matching branch is taken. Contacts not matching any
|
||||
branch follow the Default path.
|
||||
</p>
|
||||
|
||||
{conditionBranches.map((branch, idx) => (
|
||||
<div
|
||||
key={branch.id}
|
||||
className="p-3 border border-neutral-200 rounded-lg space-y-3 bg-neutral-50/50"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">Branch {idx + 1}</span>
|
||||
{conditionBranches.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConditionBranches(prev => prev.filter(b => b.id !== branch.id))}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">Name *</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.name}
|
||||
onChange={e => {
|
||||
const newName = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, name: newName} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="e.g., Premium, Free, Enterprise"
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">Operator</Label>
|
||||
<Select
|
||||
value={branch.operator}
|
||||
onValueChange={val =>
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, operator: val} : b)),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validOperators.map(op => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!['exists', 'notExists'].includes(branch.operator) && (
|
||||
<div>
|
||||
<Label className="text-xs">Value</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={branch.value}
|
||||
onChange={e => {
|
||||
const newValue = e.target.value;
|
||||
setConditionBranches(prev =>
|
||||
prev.map(b => (b.id === branch.id ? {...b, value: newValue} : b)),
|
||||
);
|
||||
}}
|
||||
placeholder="Value..."
|
||||
className="mt-1 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{conditionBranches.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setConditionBranches(prev => [
|
||||
...prev,
|
||||
{id: crypto.randomUUID().slice(0, 8), name: '', operator: 'equals', value: ''},
|
||||
])
|
||||
}
|
||||
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="p-2 bg-neutral-100 rounded-lg text-xs text-neutral-600 flex items-start gap-2">
|
||||
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
Branches are evaluated in order. The first match wins. Contacts not matching any branch will
|
||||
follow the <strong>Default</strong> path.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './global.css';
|
||||
import {DocsLayout} from 'fumadocs-ui/layouts/docs';
|
||||
import {RootProvider} from 'fumadocs-ui/provider/next';
|
||||
import Script from 'next/script';
|
||||
import type {ReactNode} from 'react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -25,23 +26,23 @@ export default function Layout({children}: {children: ReactNode}) {
|
||||
|
||||
{/* Open Graph / Facebook */}
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="og:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="og:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="og:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="og:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Twitter */}
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://next-wiki.useplunk.com/" />
|
||||
<meta property="twitter:url" content="https://docs.useplunk.com/" />
|
||||
<meta property="twitter:title" content="Plunk Documentation" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Documentation for Plunk, the open-source email platform. Learn how to integrate Plunk into your application and manage your email communications."
|
||||
/>
|
||||
<meta property="twitter:image" content="https://next-wiki.useplunk.com/assets/card.png" />
|
||||
<meta property="twitter:image" content="https://docs.useplunk.com/assets/card.png" />
|
||||
|
||||
{/* Fonts */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -64,6 +65,12 @@ export default function Layout({children}: {children: ReactNode}) {
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
</head>
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.driaug.com/script.js"
|
||||
data-website-id="ba0b7094-e693-492e-902a-c62aab868715"
|
||||
data-domains="docs.useplunk.com"
|
||||
/>
|
||||
<body className="flex flex-col min-h-screen antialiased text-neutral-800" suppressHydrationWarning>
|
||||
<RootProvider
|
||||
theme={{
|
||||
|
||||
@@ -244,11 +244,13 @@ For a complete list of error codes and troubleshooting guidance, see the [Error
|
||||
### Segments
|
||||
|
||||
**GET /segments** — List all segments
|
||||
**POST /segments** — Create new segment
|
||||
**POST /segments** — Create new segment (Dynamic or Static)
|
||||
**GET /segments/:id** — Get segment details
|
||||
**PATCH /segments/:id** — Update segment
|
||||
**DELETE /segments/:id** — Delete segment
|
||||
**GET /segments/:id/contacts** — List segment members
|
||||
**POST /segments/:id/members** — Add contacts to a static segment (by email)
|
||||
**DELETE /segments/:id/members** — Remove contacts from a static segment (by email)
|
||||
|
||||
### Workflows
|
||||
|
||||
@@ -335,6 +337,6 @@ curl -X POST {{API_URL}}/v1/send \
|
||||
|
||||
## What's next
|
||||
|
||||
- [Send your first email](/getting-started/quick-start)
|
||||
- [View detailed endpoint docs](/api-reference/public-api)
|
||||
- [Check error codes](/api-reference/errors)
|
||||
- [Send a transactional email](/api-reference/public-api/sendEmail)
|
||||
- [Track an event](/api-reference/public-api/trackEvent)
|
||||
- [Error codes reference](/api-reference/errors)
|
||||
|
||||
@@ -15,4 +15,5 @@ After upgrading from the free tier, you will be charged per email sent at $0.001
|
||||
You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings.
|
||||
|
||||
## Special considerations
|
||||
- Emails that contain an attachment will incur double the cost (e.g. 1 email with attachment = 2 emails for billing purposes)
|
||||
- Emails that contain an attachment will incur double the cost (e.g. 1 email with attachment = 2 emails for billing purposes)
|
||||
- Inbound emails count towards the email limit at the same rate as outbound emails (e.g. 1 inbound email = 1 email for billing purposes)
|
||||
@@ -1,20 +1,56 @@
|
||||
---
|
||||
title: Segments
|
||||
description: Group and target your contacts with dynamic segments
|
||||
description: Group and target your contacts with dynamic or static segments
|
||||
icon: Layers
|
||||
---
|
||||
|
||||
Segments in Plunk allow you to create dynamic groups of contacts based on [contact data](/concepts/contacts#contact-data) and events.
|
||||
Segments let you create named groups of contacts that can be targeted in campaigns and used as triggers in workflows. There are two types: **Dynamic** and **Static**.
|
||||
|
||||
## Creating segments
|
||||
Segments can be created through the Plunk dashboard. When creating a segment, you can define multiple conditions that contacts must meet to be included in the segment. Plunk will automatically update the segment membership as contact data and events change.
|
||||
## Dynamic segments
|
||||
|
||||
### Track Membership Changes
|
||||
Plunk will automatically add and remove contacts from segments as their data and events change. When toggling on `Track membership changes`, Plunk will send an event to your webhook each time a contact is added or removed from the segment.
|
||||
Dynamic segments evaluate a set of filter conditions against your contacts in real time. Membership is kept up to date automatically as contact data and events change — no manual work required.
|
||||
|
||||
These events will have the following name `segment.trial-users.entry` or `segment.trial-users.exit`, where `trial-users` is the segment's name.
|
||||
You can filter on:
|
||||
- Contact fields (`email`, `subscribed`, custom data fields like `data.plan`)
|
||||
- Contact dates (`createdAt`, `updatedAt`)
|
||||
- Custom events (`event.signed_up`, `event.purchased`, …)
|
||||
- Email activity (`email.opened`, `email.clicked`, `email.bounced`, …)
|
||||
|
||||
Conditions can be combined with `AND`/`OR` logic and nested into groups for complex rules.
|
||||
|
||||
## Static segments
|
||||
|
||||
Static segments are manually curated lists. Membership does not change automatically — you decide exactly who is in the segment. This is useful for things like beta testers, event attendees, or any group imported from an external source.
|
||||
|
||||
## Creating a segment
|
||||
|
||||
Go to **Segments** in the dashboard and click **Create Segment**. Use the toggle at the top to choose **Dynamic** or **Static**.
|
||||
|
||||
**For dynamic segments**, use the filter builder to define your conditions. Plunk will show you a live count of matching contacts.
|
||||
|
||||
**For static segments**, you can optionally add initial members right away using the contact search. Start typing an email address and select contacts from the list — selected contacts appear as chips you can remove before saving.
|
||||
|
||||
## Managing static segment members
|
||||
|
||||
Open a static segment and use the **Add Members** search to find and select contacts. The search looks up contacts already in your project, so you can't accidentally add someone who doesn't exist. Contacts already in the segment are greyed out.
|
||||
|
||||
To remove a member, click the remove button on their row in the members list.
|
||||
|
||||
## Track membership changes
|
||||
|
||||
Both segment types support **Track membership changes**. When enabled, Plunk fires a webhook event each time a contact enters or leaves the segment:
|
||||
|
||||
- `segment.trial-users.entry` — contact joined the segment
|
||||
- `segment.trial-users.exit` — contact left the segment
|
||||
|
||||
Where `trial-users` is derived from the segment name. See [Webhooks](/guides/webhooks) for the full event payload.
|
||||
|
||||
## Using segments
|
||||
Segment can be used in various parts of Plunk, including:
|
||||
|
||||
Segments can be used in:
|
||||
- Targeting contacts in [email campaigns](/concepts/campaigns)
|
||||
- Triggering workflows in [marketing automation](/concepts/workflows)
|
||||
- Triggering workflows in [marketing automation](/concepts/workflows)
|
||||
|
||||
## Managing members via API
|
||||
|
||||
If you need to manage static segment membership programmatically, use the `POST /segments/:id/members` and `DELETE /segments/:id/members` endpoints. See the [API reference](/api-reference/overview#segments) for details.
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Send real-time event data from Plunk to your own application using
|
||||
icon: Webhook
|
||||
---
|
||||
|
||||
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
|
||||
|
||||
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
|
||||
|
||||
## How it works
|
||||
@@ -104,7 +106,13 @@ When using the default payload (no custom body configured), Plunk sends a JSON r
|
||||
"event": {
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"bounceType": "Permanent"
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": "camp_abc123",
|
||||
"sourceType": "CAMPAIGN",
|
||||
"bounceType": "Permanent",
|
||||
"bouncedAt": "2025-01-15T10:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -113,16 +121,268 @@ The `event` field contains the data associated with the event that triggered the
|
||||
|
||||
### Event data by type
|
||||
|
||||
The `event` field varies depending on which event triggered the workflow:
|
||||
#### Email events
|
||||
|
||||
| Event | Fields in `event` |
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
|
||||
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
|
||||
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
|
||||
| `email.bounce` | `subject`, `from`, `bounceType`, `bouncedAt` |
|
||||
| `email.complaint` | `subject`, `from`, `complainedAt` |
|
||||
| Custom events | Whatever data you passed when tracking the event |
|
||||
Most email events share a common set of base fields:
|
||||
|
||||
| Field | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------------- |
|
||||
| `subject` | The email subject line |
|
||||
| `from` | The sender email address |
|
||||
| `fromName` | The sender display name |
|
||||
| `messageId` | The AWS SES message ID |
|
||||
| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
|
||||
| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
|
||||
| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
|
||||
|
||||
In addition to these base fields, each event includes the following event-specific fields:
|
||||
|
||||
<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
|
||||
|
||||
<Tab value="email.sent">
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"sentAt": "2025-01-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------- | ------------------------ |
|
||||
| `sentAt` | When the email was sent |
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.delivery">
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": "camp_abc123",
|
||||
"sourceType": "CAMPAIGN",
|
||||
"deliveredAt": "2025-01-15T10:30:05.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | ------------------------------- |
|
||||
| `deliveredAt` | When the email was delivered |
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.open">
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"openedAt": "2025-01-15T11:00:00.000Z",
|
||||
"opens": 1,
|
||||
"isFirstOpen": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | ------------------------------------------------------------ |
|
||||
| `openedAt` | When the email was first opened |
|
||||
| `opens` | Total number of times this email has been opened |
|
||||
| `isFirstOpen` | `true` if this is the first time the contact opened the email |
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.click">
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"link": "https://example.com/pricing",
|
||||
"clickedAt": "2025-01-15T11:05:00.000Z",
|
||||
"clicks": 1,
|
||||
"isFirstClick": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------------- | -------------------------------------------------------------- |
|
||||
| `link` | The URL that was clicked |
|
||||
| `clickedAt` | When the first click occurred |
|
||||
| `clicks` | Total number of times links in this email have been clicked |
|
||||
| `isFirstClick` | `true` if this is the first click from this contact on this email |
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.bounce">
|
||||
|
||||
Permanent bounce:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"bounceType": "Permanent",
|
||||
"bouncedAt": "2025-01-15T10:31:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
Transient (soft) bounce:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"bounceType": "Transient",
|
||||
"transientBounce": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
|
||||
| `bouncedAt` | When the bounce occurred (permanent bounces only) |
|
||||
| `transientBounce`| `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
|
||||
|
||||
<Callout title="Bounce rate impact" variant="warn">
|
||||
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription. `Transient` bounces are tracked for visibility only.
|
||||
</Callout>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.complaint">
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "Welcome to Plunk",
|
||||
"from": "hello@example.com",
|
||||
"fromName": "Plunk Team",
|
||||
"messageId": "ses-message-id",
|
||||
"templateId": null,
|
||||
"campaignId": null,
|
||||
"sourceType": "TRANSACTIONAL",
|
||||
"complainedAt": "2025-01-15T10:35:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| `complainedAt` | When the spam complaint was received |
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="email.received">
|
||||
|
||||
This event fires when an email is received at your verified domain. See [Receiving Emails](/guides/receiving-emails) for setup instructions.
|
||||
|
||||
```json
|
||||
{
|
||||
"messageId": "ses-message-id",
|
||||
"from": "sender@example.com",
|
||||
"fromHeader": "Jane Smith <sender@example.com>",
|
||||
"to": "support@yourdomain.com",
|
||||
"subject": "Re: Your question",
|
||||
"timestamp": "2025-01-15T10:30:00.000Z",
|
||||
"recipients": ["support@yourdomain.com"],
|
||||
"hasContent": true,
|
||||
"spamVerdict": "PASS",
|
||||
"virusVerdict": "PASS",
|
||||
"spfVerdict": "PASS",
|
||||
"dkimVerdict": "PASS",
|
||||
"dmarcVerdict": "PASS",
|
||||
"processingTimeMillis": 142
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ------------------------------------------------------------------- |
|
||||
| `messageId` | The AWS SES message ID |
|
||||
| `from` | The sender's email address |
|
||||
| `fromHeader` | The full `From` header, including display name if present |
|
||||
| `to` | The recipient address at your verified domain |
|
||||
| `subject` | The email subject line |
|
||||
| `timestamp` | When SES received the email |
|
||||
| `recipients` | All recipient addresses in the envelope |
|
||||
| `hasContent` | Whether the email body content is available |
|
||||
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
|
||||
| `virusVerdict` | SES virus check result |
|
||||
| `spfVerdict` | SPF authentication result |
|
||||
| `dkimVerdict` | DKIM authentication result |
|
||||
| `dmarcVerdict` | DMARC authentication result |
|
||||
| `processingTimeMillis` | Time SES took to process the inbound email |
|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
#### Contact events
|
||||
|
||||
`contact.subscribed` and `contact.unsubscribed` carry no event data by default. The `event` field will be an empty object `{}`.
|
||||
|
||||
The exception is when an unsubscription is triggered automatically by an email bounce or complaint — in that case `event` includes a `reason` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "bounce"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
| -------- | -------------------------------------------------- |
|
||||
| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
|
||||
|
||||
#### Segment events
|
||||
|
||||
Both `segment.<name>.entry` and `segment.<name>.exit` include:
|
||||
|
||||
```json
|
||||
{
|
||||
"segmentId": "seg_abc123",
|
||||
"segmentName": "VIP Users"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | ----------------------------- |
|
||||
| `segmentId` | The ID of the segment |
|
||||
| `segmentName` | The display name of the segment |
|
||||
|
||||
#### Custom events
|
||||
|
||||
Custom events tracked via the API include whatever data you passed in the `data` field when calling `track`.
|
||||
|
||||
#### No event data
|
||||
|
||||
For events that carry no data, the `event` field will be an empty object `{}`.
|
||||
|
||||
## Common use cases
|
||||
|
||||
|
||||
@@ -3,64 +3,118 @@ title: Environment Variables
|
||||
description: Configuration reference
|
||||
---
|
||||
|
||||
## Required
|
||||
## Security & Database
|
||||
|
||||
```bash
|
||||
# Security
|
||||
JWT_SECRET="your-secret" # openssl rand -base64 32
|
||||
DB_PASSWORD="your-password"
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `JWT_SECRET` | Yes | Secret key used to sign JWT tokens. Generate with `openssl rand -base64 32`. | `s3cr3t...` |
|
||||
| `DB_PASSWORD` | Yes | PostgreSQL database password. Used by the Docker Compose setup. | `changeme123` |
|
||||
| `DATABASE_URL` | Yes | Full PostgreSQL connection string. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
|
||||
| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
|
||||
| `PORT` | No | Port the API server listens on. | `8080` (default) |
|
||||
|
||||
# Database (auto-configured in Docker)
|
||||
DATABASE_URL="postgresql://plunk:password@postgres:5432/plunk"
|
||||
REDIS_URL="redis://redis:6379"
|
||||
## URLs & Domains
|
||||
|
||||
# AWS SES
|
||||
AWS_SES_REGION="us-east-1"
|
||||
AWS_SES_ACCESS_KEY_ID="your-key"
|
||||
AWS_SES_SECRET_ACCESS_KEY="your-secret"
|
||||
SES_CONFIGURATION_SET="plunk-tracking"
|
||||
```
|
||||
Set your subdomains here. The application automatically derives all internal and client-side URLs from these at container startup — you don't need to set `*_URI` or `NEXT_PUBLIC_*` variables manually.
|
||||
|
||||
## Domains
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `API_DOMAIN` | Yes | Subdomain for the API server. | `api.yourdomain.com` |
|
||||
| `DASHBOARD_DOMAIN` | Yes | Subdomain for the dashboard app. | `app.yourdomain.com` |
|
||||
| `LANDING_DOMAIN` | Yes | Subdomain for the landing page. | `www.yourdomain.com` |
|
||||
| `WIKI_DOMAIN` | Yes | Subdomain for the documentation site. | `docs.yourdomain.com` |
|
||||
| `USE_HTTPS` | No | Set to `true` when running behind a TLS-terminating reverse proxy. Used to construct URLs with the correct protocol. | `false` (default) |
|
||||
|
||||
```bash
|
||||
# Subdomains for routing
|
||||
API_DOMAIN="api.yourdomain.com"
|
||||
DASHBOARD_DOMAIN="app.yourdomain.com"
|
||||
LANDING_DOMAIN="www.yourdomain.com"
|
||||
WIKI_DOMAIN="docs.yourdomain.com"
|
||||
SMTP_DOMAIN="smtp.yourdomain.com"
|
||||
## AWS SES
|
||||
|
||||
# Protocol
|
||||
USE_HTTPS="true" # false for local dev
|
||||
```
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `AWS_SES_REGION` | Yes | AWS region where SES is configured. | `us-east-1` |
|
||||
| `AWS_SES_ACCESS_KEY_ID` | Yes | AWS access key ID with SES send permissions. | `AKIA...` |
|
||||
| `AWS_SES_SECRET_ACCESS_KEY` | Yes | AWS secret access key for SES. | `wJalr...` |
|
||||
| `SES_CONFIGURATION_SET` | No | SES configuration set name used for open/click tracking. | `plunk-configuration-set` (default) |
|
||||
| `SES_CONFIGURATION_SET_NO_TRACKING` | No | A second SES configuration set without tracking. When set, projects can toggle email tracking on/off. If omitted, the tracking toggle is hidden. | `plunk-no-tracking-configuration-set` (default) |
|
||||
|
||||
## Storage (Minio)
|
||||
|
||||
Defaults work with bundled Minio. Only set for external S3:
|
||||
The bundled Docker setup includes Minio with defaults that work out of the box. Only change these when connecting to an external S3-compatible bucket.
|
||||
|
||||
```bash
|
||||
S3_ENDPOINT="http://minio:9000"
|
||||
S3_ACCESS_KEY_ID="plunk"
|
||||
S3_ACCESS_KEY_SECRET="plunkminiopass"
|
||||
S3_BUCKET="uploads"
|
||||
S3_PUBLIC_URL="http://localhost:9000/uploads"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
```
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `MINIO_ROOT_USER` | No | Minio root username (Docker Compose only). | `plunk` |
|
||||
| `MINIO_ROOT_PASSWORD` | No | Minio root password (Docker Compose only). | `plunkminiopass` |
|
||||
| `MINIO_API_PORT` | No | Port for the Minio API (Docker Compose only). | `9000` |
|
||||
| `MINIO_CONSOLE_PORT` | No | Port for the Minio console UI (Docker Compose only). | `9001` |
|
||||
| `S3_ENDPOINT` | No | S3 or Minio endpoint URL. | `http://minio:9000` |
|
||||
| `S3_ACCESS_KEY_ID` | No | S3 or Minio access key. | — |
|
||||
| `S3_ACCESS_KEY_SECRET` | No | S3 or Minio secret key. | — |
|
||||
| `S3_BUCKET` | No | Bucket name for file uploads. | `uploads` |
|
||||
| `S3_PUBLIC_URL` | No | Publicly accessible base URL for stored files. | — |
|
||||
| `S3_FORCE_PATH_STYLE` | No | Use path-style URLs instead of virtual-hosted. Required for Minio. | `true` |
|
||||
|
||||
## Optional
|
||||
## SMTP Server
|
||||
|
||||
```bash
|
||||
# OAuth
|
||||
GITHUB_OAUTH_CLIENT="client-id"
|
||||
GITHUB_OAUTH_SECRET="client-secret"
|
||||
GOOGLE_OAUTH_CLIENT="client-id"
|
||||
GOOGLE_OAUTH_SECRET="client-secret"
|
||||
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
|
||||
|
||||
# Stripe
|
||||
STRIPE_SK="sk_..."
|
||||
STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `SMTP_DOMAIN` | No | SMTP relay domain. Required when using Traefik's `acme.json` with multiple certificates so the correct cert can be selected. | `localhost` |
|
||||
| `SMTP_ENABLED` | No | Explicitly enable SMTP features in the UI. Automatically enabled when `SMTP_DOMAIN` is set to a non-localhost value in production. | `false` |
|
||||
| `PORT_SECURE` | No | SMTPS port (implicit TLS). | `465` |
|
||||
| `PORT_SUBMISSION` | No | SMTP submission port (STARTTLS). | `587` |
|
||||
| `MAX_RECIPIENTS` | No | Maximum number of recipients per email. | `5` |
|
||||
|
||||
# Notifications
|
||||
NTFY_URL="http://ntfy/plunk-notifications"
|
||||
```
|
||||
## OAuth
|
||||
|
||||
Enables social login. Register an OAuth app with each provider and add the credentials here.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_OAUTH_CLIENT` | No | GitHub OAuth app client ID. |
|
||||
| `GITHUB_OAUTH_SECRET` | No | GitHub OAuth app client secret. |
|
||||
| `GOOGLE_OAUTH_CLIENT` | No | Google OAuth app client ID. |
|
||||
| `GOOGLE_OAUTH_SECRET` | No | Google OAuth app client secret. |
|
||||
|
||||
## Stripe
|
||||
|
||||
Required if you want to enable billing features. All Stripe variables must be set together for billing to activate.
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `STRIPE_SK` | No | Stripe secret key. |
|
||||
| `STRIPE_WEBHOOK_SECRET` | No | Stripe webhook signing secret for verifying events. |
|
||||
| `STRIPE_PRICE_ONBOARDING` | No | Stripe price ID for the one-time onboarding fee. |
|
||||
| `STRIPE_PRICE_EMAIL_USAGE` | No | Stripe price ID for metered pay-per-email usage. |
|
||||
| `STRIPE_METER_EVENT_NAME` | No | Stripe meter event name. | `emails` (default) |
|
||||
|
||||
## Platform Emails
|
||||
|
||||
When configured, Plunk will send email notifications to users for critical events (e.g. project disabled, billing limits reached). Without these, only ntfy notifications are sent.
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|---|---|---|---|
|
||||
| `PLUNK_API_KEY` | No | API key for a Plunk instance to send transactional emails. | `pk_...` |
|
||||
| `PLUNK_FROM_ADDRESS` | No | From address used for platform notification emails. | `noreply@yourdomain.com` |
|
||||
|
||||
## Notifications (ntfy)
|
||||
|
||||
Plunk bundles a self-hosted [ntfy](https://ntfy.sh) server for internal system notifications.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `NTFY_PORT` | No | Port for the ntfy web UI (Docker Compose only). | `8080` |
|
||||
| `NTFY_URL` | No | ntfy topic URL. Change this to use an external ntfy.sh server or your own instance. | `http://ntfy/plunk-notifications` |
|
||||
|
||||
## User Management
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `DISABLE_SIGNUPS` | No | When `true`, the signup endpoint rejects new registrations. Useful for private instances. | `false` |
|
||||
| `VERIFY_EMAIL_ON_SIGNUP` | No | When `true`, validates emails on signup — checks for disposable domains, plus-addressing, domain existence, and MX records. | `false` |
|
||||
|
||||
## Security
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `AUTO_PROJECT_DISABLE` | No | When `true`, projects are automatically suspended when bounce or complaint rate thresholds are exceeded. Set to `false` to manage project status manually. | `true` |
|
||||
| `EMAIL_RATE_LIMIT_PER_SECOND` | No | Override the email sending rate limit. If not set, Plunk automatically fetches the quota from your AWS SES account. | — |
|
||||
|
||||
@@ -46,7 +46,7 @@ function filePathToUrl(filePath) {
|
||||
}
|
||||
|
||||
const config = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://next-wiki.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_WIKI_URI || 'https://docs.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
additionalPaths: async () => {
|
||||
const contentDocsPath = path.join(__dirname, 'content', 'docs');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "Plunk Support",
|
||||
"url": "https://next.useplunk.com"
|
||||
"url": "https://www.useplunk.com"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.1.5",
|
||||
"next": "^16.1.7",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -19,8 +19,8 @@ echo "🔍 Generating URL manifest for $APP_NAME..."
|
||||
# Define placeholder URLs that will be replaced at runtime
|
||||
PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Output manifest file
|
||||
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
|
||||
|
||||
@@ -13,8 +13,8 @@ replace_urls_in_app() {
|
||||
# Define placeholder URLs (built into the app at compile time)
|
||||
local PLACEHOLDER_API="https://next-api.useplunk.com"
|
||||
local PLACEHOLDER_DASHBOARD="https://next-app.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://next.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
||||
local PLACEHOLDER_LANDING="https://www.useplunk.com"
|
||||
local PLACEHOLDER_WIKI="https://docs.useplunk.com"
|
||||
|
||||
# Use pre-generated manifest instead of scanning all files
|
||||
local manifest_file="$app_dir/.next/url-manifest.txt"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.6.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SegmentType" AS ENUM ('DYNAMIC', 'STATIC');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "segments" ADD COLUMN "type" "SegmentType" NOT NULL DEFAULT 'DYNAMIC',
|
||||
ALTER COLUMN "condition" DROP NOT NULL;
|
||||
@@ -199,8 +199,11 @@ model Segment {
|
||||
name String
|
||||
description String?
|
||||
|
||||
// Filter condition (evaluated dynamically)
|
||||
condition Json
|
||||
// Segment type: DYNAMIC (filter-based) or STATIC (manually managed)
|
||||
type SegmentType @default(DYNAMIC)
|
||||
|
||||
// Filter condition (evaluated dynamically, null for STATIC segments)
|
||||
condition Json?
|
||||
// Nested filter structure with AND/OR logic:
|
||||
// {
|
||||
// logic: "OR",
|
||||
@@ -747,6 +750,11 @@ enum StepExecutionStatus {
|
||||
FAILED // Failed with error
|
||||
}
|
||||
|
||||
enum SegmentType {
|
||||
DYNAMIC // Filter-based segment evaluated against contacts at query time
|
||||
STATIC // Manually curated list of contacts managed via memberships
|
||||
}
|
||||
|
||||
enum EmailSourceType {
|
||||
TRANSACTIONAL // Sent via API call
|
||||
CAMPAIGN // Sent as part of broadcast
|
||||
|
||||
@@ -6,7 +6,7 @@ interface FooterProps {
|
||||
landingUrl?: string;
|
||||
}
|
||||
|
||||
export function Footer({projectId, landingUrl = 'https://next.useplunk.com'}: FooterProps) {
|
||||
export function Footer({projectId, landingUrl = 'https://www.useplunk.com'}: FooterProps) {
|
||||
return (
|
||||
<Section className="border-t border-gray-100 bg-gray-50 px-8 py-8">
|
||||
<Text className="mb-4 mt-0 text-center text-xs leading-relaxed text-gray-600">
|
||||
|
||||
@@ -5,7 +5,7 @@ export function Header() {
|
||||
return (
|
||||
<Section className="border-b border-gray-100 bg-white px-8 py-8">
|
||||
<Img
|
||||
src="https://next.useplunk.com/assets/logo.png"
|
||||
src="https://www.useplunk.com/assets/logo.png"
|
||||
alt="Plunk"
|
||||
width="40"
|
||||
height="40"
|
||||
|
||||
@@ -21,7 +21,7 @@ export function BillingLimitExceededEmail({
|
||||
limit = 10000,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitExceededEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function BillingLimitWarningEmail({
|
||||
percentage = 85,
|
||||
sourceType = 'Transactional',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: BillingLimitWarningEmailProps) {
|
||||
const percentageRounded = Math.round(percentage);
|
||||
const remaining = limit - usage;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainUnverifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainUnverifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DomainVerifiedEmail({
|
||||
projectId = 'proj_example123',
|
||||
domain = 'example.com',
|
||||
dashboardUrl = 'https://next-app.useplunk.com',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: DomainVerifiedEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface EmailVerificationEmailProps {
|
||||
export function EmailVerificationEmail({
|
||||
email = 'user@example.com',
|
||||
verificationUrl = 'https://api.useplunk.com/auth/verify-email?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: EmailVerificationEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PasswordResetEmailProps {
|
||||
export function PasswordResetEmail({
|
||||
email = 'user@example.com',
|
||||
resetUrl = 'https://next-app.useplunk.com/auth/reset-password?token=abc123',
|
||||
landingUrl = 'https://next.useplunk.com',
|
||||
landingUrl = 'https://www.useplunk.com',
|
||||
}: PasswordResetEmailProps) {
|
||||
return (
|
||||
<EmailLayout>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user