Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d475c382b | ||
|
|
3fea06ba2d | ||
|
|
1d1d407a6f | ||
|
|
46b2b8390f | ||
|
|
9e4aa9443b | ||
|
|
a27d564e1a | ||
|
|
4e95c5a4e4 | ||
|
|
463301b5db | ||
|
|
f178c59b5b | ||
|
|
6195b39f3d | ||
|
|
e0bf0f628a | ||
|
|
8a26d605b4 | ||
|
|
715961c007 | ||
|
|
ccd516e4e8 | ||
|
|
649bbf6d6b | ||
|
|
e43f70d8a1 | ||
|
|
f22da4add1 | ||
|
|
de6335e999 | ||
|
|
7658a59b5d | ||
|
|
aaf5ac6530 | ||
|
|
c6340a1dc7 | ||
|
|
4ba43dd3b6 | ||
|
|
d11495061d | ||
|
|
fadc19d139 | ||
|
|
2ea1802290 | ||
|
|
15bdcf6e49 | ||
|
|
2529c9b428 | ||
|
|
4ddafdc041 | ||
|
|
88be252a29 | ||
|
|
db5dfc46ce | ||
|
|
e2ce5d4e96 |
+13
-4
@@ -1,13 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.turbo
|
||||
out
|
||||
# Patterns without a leading `**/` only match the build-context root, NOT
|
||||
# nested directories. In this monorepo the actual outputs live under
|
||||
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/`
|
||||
# any stale local builds (from running `yarn dev` or `yarn build` on the
|
||||
# host) get copied into the image and override the freshly-built outputs,
|
||||
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
|
||||
# bundle still references).
|
||||
**/dist
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/out
|
||||
|
||||
# Development
|
||||
.env
|
||||
|
||||
+17
-14
@@ -52,6 +52,13 @@ SES_CONFIGURATION_SET=plunk-configuration-set
|
||||
# When set, projects can choose to disable email tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
||||
|
||||
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
|
||||
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
# is already used for something else (e.g. an R2/CDN custom domain), since the
|
||||
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
|
||||
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
|
||||
# MAIL_FROM_SUBDOMAIN=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: OAuth Login
|
||||
# ========================================
|
||||
@@ -60,15 +67,6 @@ GITHUB_OAUTH_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Stripe Billing
|
||||
# ========================================
|
||||
STRIPE_SK=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING=
|
||||
STRIPE_PRICE_EMAIL_USAGE=
|
||||
STRIPE_METER_EVENT_NAME=emails
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: File Storage (Minio)
|
||||
# ========================================
|
||||
@@ -154,11 +152,16 @@ SMTP_DOMAIN=smtp.example.com
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Controls whether email validation checks are performed on signup
|
||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||
# When disabled (false), skips these validation checks and allows any email format
|
||||
# Default: false
|
||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||
# ========================================
|
||||
# OPTIONAL: SES Sending Rate
|
||||
# ========================================
|
||||
# Caps the email-worker's send rate (messages per second) for the SES sandbox or
|
||||
# manually-throttled accounts. When unset, the worker probes the AWS account at
|
||||
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
|
||||
# the worker silently falls back to 14/sec which may exceed sandbox limits and
|
||||
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
|
||||
# Default: unset (auto-detect, falls back to 14)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=1
|
||||
|
||||
# ========================================
|
||||
# ADVANCED (rarely needed)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.10.0"
|
||||
".": "0.11.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [0.11.0](https://github.com/useplunk/plunk/compare/v0.10.0...v0.11.0) (2026-05-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change subscription status in workflows ([fadc19d](https://github.com/useplunk/plunk/commit/fadc19d139084550eb442a8b8368edcd6075cad0))
|
||||
* add 'notTriggeredWithin' operator to segment filters for enhanced event tracking ([0f00ca1](https://github.com/useplunk/plunk/commit/0f00ca1b8c9bd95f2158af845218ef432ab0d498))
|
||||
* add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder ([4ba43dd](https://github.com/useplunk/plunk/commit/4ba43dd3b64776413f813e091f527aa3e56bfc84))
|
||||
* add Markdown cut link to footer and page for improved accessibility ([ccd516e](https://github.com/useplunk/plunk/commit/ccd516e4e882921fb1a8a3e87c47710e51e35311))
|
||||
* add project switching functionality to command palette ([5724ab9](https://github.com/useplunk/plunk/commit/5724ab9536ea4e3bb4e916e963b1f39ba51bb7f3))
|
||||
* add sanitize-html for improved email content sanitization ([735acff](https://github.com/useplunk/plunk/commit/735acff45423cb537f7aa636b9a9d31830775f93))
|
||||
* add search functionality to campaigns list with debounce effect ([d114950](https://github.com/useplunk/plunk/commit/d11495061d907bcee699d56987784fa7ebe31bf6))
|
||||
* add segment membership operators and enhance segment filter functionality ([a3cc622](https://github.com/useplunk/plunk/commit/a3cc62213f40f6a9341113b73b52852292fb9a10))
|
||||
* add SwitchOffer component to promote switching from competitors for enhanced user engagement ([6195b39](https://github.com/useplunk/plunk/commit/6195b39f3dbdd9fe783b79f9ae7f7942509a49a0))
|
||||
* add workflow duplication functionality with API endpoint and UI button ([c6340a1](https://github.com/useplunk/plunk/commit/c6340a1dc7385723d4ec30779340218935d814e6))
|
||||
* enhance campaign scheduling and audience settings UI for better clarity and usability ([7658a59](https://github.com/useplunk/plunk/commit/7658a59b5df4e325ad9eca37356cad3cbb70e942))
|
||||
* implement bulk contact action selector for improved flexibility in bulk operations ([de6335e](https://github.com/useplunk/plunk/commit/de6335e99999242f81e9eda9a20aeccab80a2de4))
|
||||
* implement caching for recent activity count to optimize performance and reduce database load ([f22da4a](https://github.com/useplunk/plunk/commit/f22da4add1e455678f380aba8c2fd02012ef6457))
|
||||
* implement early fraud warning handling in webhooks ([48425d1](https://github.com/useplunk/plunk/commit/48425d1df160a3fd5edb4cbdcc9e6725628db319))
|
||||
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env ([463301b](https://github.com/useplunk/plunk/commit/463301b5db27b08b483db3c56a992ac62d6653b8))
|
||||
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var ([e0bf0f6](https://github.com/useplunk/plunk/commit/e0bf0f628af2a155ca3bbc38ef5c2f4ecc0455e6))
|
||||
* refactor template editing layout for improved usability and clarity ([aaf5ac6](https://github.com/useplunk/plunk/commit/aaf5ac65307266a4d9deb02986b610757e929b49))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enhance campaign finalization process to handle pending emails and ensure accurate status updates ([2529c9b](https://github.com/useplunk/plunk/commit/2529c9b4282b003a94013259119e98654ae22b46))
|
||||
* enhance email content parsing and logging for better debugging ([4587e9d](https://github.com/useplunk/plunk/commit/4587e9d670fa3a27fbc1ca7669c8fc75d804e85e))
|
||||
* enhance project name validation to exclude invisible and decorative characters ([29883ff](https://github.com/useplunk/plunk/commit/29883ffc7104d4beb13965bfa65ea609367ede69))
|
||||
* enhance Quick Start card layout for improved responsiveness and usability ([649bbf6](https://github.com/useplunk/plunk/commit/649bbf6d6b9db5139295a455c69425a5565d4323))
|
||||
* handle undefined path in footer component for improved stability ([715961c](https://github.com/useplunk/plunk/commit/715961c007e450731434e5f343acff0debd6a851))
|
||||
* implement mergeContactData method for efficient contact data updates ([a27d564](https://github.com/useplunk/plunk/commit/a27d564e1af6e5cf72c9ef405650f2475bfd0c86))
|
||||
* improve iframe height adjustment logic in EmailEditor component ([9e4aa94](https://github.com/useplunk/plunk/commit/9e4aa9443b53ef38a3af1b269f4a949dd57e758f))
|
||||
* pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example ([8a26d60](https://github.com/useplunk/plunk/commit/8a26d605b4aa5fdea0e02a17159c1cae478a7e47))
|
||||
* pass missing env variables from .env.self-host.example to plunk service ([f178c59](https://github.com/useplunk/plunk/commit/f178c59b5bc461f9ba57dc6f0bac9617feaab4a3))
|
||||
* replace font loading method with utility function for improved performance ([ba23a1c](https://github.com/useplunk/plunk/commit/ba23a1c6fa70ca6fa0343391fa8d642155ad45c2))
|
||||
* update activity item colors and backgrounds for improved visual distinction ([e43f70d](https://github.com/useplunk/plunk/commit/e43f70d8a135b048d4e9ed013ffa25f1b2f89687))
|
||||
* update middleware matcher to exclude webmanifest files ([cbb3bff](https://github.com/useplunk/plunk/commit/cbb3bffcf4f789e9c36912c9b0ce0ba9f6a1d4dd))
|
||||
* update not found handling in GET route to return 404 response ([7615b62](https://github.com/useplunk/plunk/commit/7615b62945aa1939bf6a5b3638c232509090de60))
|
||||
* update segment filter logic to retain value and unit, enhance activity name mapping ([d1f357c](https://github.com/useplunk/plunk/commit/d1f357c300c7798d5c8d521e1219e39e7da821dd))
|
||||
* update segment filter logic to retain value and unit, enhance activity name mapping ([a126563](https://github.com/useplunk/plunk/commit/a1265635420e66b7cdef02a7136fdf9c3aec4418))
|
||||
* update TemplateSearchPicker to maintain selected template name on change ([2ea1802](https://github.com/useplunk/plunk/commit/2ea1802290a4378d68e3878a44e502ea8541b301))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* convert forwardRef components to function components for consistency ([361ec0b](https://github.com/useplunk/plunk/commit/361ec0b1eb17647696656c6a6d4cddd5b9348b45))
|
||||
* implement step dialog components for workflow editing ([ed9027b](https://github.com/useplunk/plunk/commit/ed9027b4ef9c37e91c69ed5a90d5b74c08c7a054))
|
||||
* remove unused .png files ([2d05c3f](https://github.com/useplunk/plunk/commit/2d05c3fbc1ded87880a307943bb93106b18cd406))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* expand documentation with new sections on importing contacts, unsubscribe pages, and API key management ([4ddafdc](https://github.com/useplunk/plunk/commit/4ddafdc0419389818de7e45eb4a0c82c2e9382eb))
|
||||
* update README to clarify self-hosted alternative and add inbound emails feature ([80ef65e](https://github.com/useplunk/plunk/commit/80ef65e04b8c362f05ae5b7d5710b398a150cce5))
|
||||
|
||||
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
|
||||
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
|
||||
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
|
||||
// needs MX + TXT records that can't coexist with a CNAME.
|
||||
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
@@ -128,6 +134,6 @@ export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', '');
|
||||
export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku');
|
||||
export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails
|
||||
export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== '';
|
||||
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%)
|
||||
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % required to auto-disable project from a single detection
|
||||
export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3)
|
||||
export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour)
|
||||
|
||||
@@ -64,6 +64,7 @@ export class Campaigns {
|
||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const status = req.query.status as CampaignStatus | undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
|
||||
@@ -74,6 +75,7 @@ export class Campaigns {
|
||||
|
||||
const result = await CampaignService.list(auth.projectId, {
|
||||
status,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
NODE_ENV,
|
||||
S3_ENABLED,
|
||||
SMTP_DOMAIN,
|
||||
@@ -62,6 +63,7 @@ export class Config {
|
||||
},
|
||||
aws: {
|
||||
sesRegion: AWS_SES_REGION,
|
||||
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
import type {BulkContactActionSelector} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
@@ -424,31 +426,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk subscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'subscribe');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,30 +437,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk unsubscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'unsubscribe');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,30 +448,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk delete queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'delete');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,3 +482,36 @@ export class Contacts {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueBulkAction(
|
||||
req: Request,
|
||||
res: Response,
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
|
||||
});
|
||||
}
|
||||
|
||||
const selector: BulkContactActionSelector =
|
||||
parsed.data.mode === 'ids'
|
||||
? {mode: 'ids', contactIds: parsed.data.contactIds}
|
||||
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
|
||||
return res.status(202).json({
|
||||
message: `Bulk ${operation} queued successfully`,
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,14 @@ export class Users {
|
||||
client_reference_id: project.id, // Store project ID for webhook
|
||||
line_items: lineItems,
|
||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||
custom_fields: [
|
||||
{
|
||||
key: 'promo_code',
|
||||
label: {type: 'custom', custom: 'Promo code'},
|
||||
type: 'text',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: billingCycleAnchor,
|
||||
},
|
||||
|
||||
@@ -530,10 +530,24 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||
let creditBalance = -100;
|
||||
|
||||
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
|
||||
// into the Promo code custom field on Stripe Checkout.
|
||||
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
|
||||
const promoCode = promoField?.text?.value?.trim().toUpperCase();
|
||||
if (promoCode === 'SWITCH') {
|
||||
creditBalance -= 200;
|
||||
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
|
||||
} else if (promoCode) {
|
||||
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Update Stripe customer name to match project name and add credit for onboarding fee
|
||||
await stripe.customers.update(customerId, {
|
||||
name: updatedProject.name,
|
||||
balance: -100,
|
||||
balance: creditBalance,
|
||||
});
|
||||
|
||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||
|
||||
@@ -149,6 +149,26 @@ export class Workflows {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/duplicate
|
||||
* Duplicate a workflow (always disabled, no execution state)
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
|
||||
|
||||
return res.status(201).json(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/steps
|
||||
* Add a step to a workflow
|
||||
|
||||
@@ -3,73 +3,149 @@
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface BulkActionResult {
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
totalRequested: number;
|
||||
/** Contacts whose state was actually changed by this run. */
|
||||
successCount: number;
|
||||
/** Subscribe/unsubscribe only: contacts already in the target state. */
|
||||
unchangedCount: number;
|
||||
/** Contacts that errored or weren't found (e.g. wrong project). */
|
||||
failureCount: number;
|
||||
errors: {contactId: string; email: string; error: string}[];
|
||||
}
|
||||
|
||||
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
|
||||
const search = selector.filter?.search;
|
||||
const excludeIds = selector.excludeIds ?? [];
|
||||
return {
|
||||
projectId,
|
||||
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
|
||||
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyBatch(
|
||||
projectId: string,
|
||||
operation: BulkActionResult['operation'],
|
||||
ids: string[],
|
||||
): Promise<{changed: number; unchanged: number}> {
|
||||
switch (operation) {
|
||||
case 'subscribe': {
|
||||
const r = await ContactService.bulkSubscribe(projectId, ids);
|
||||
return {changed: r.updated, unchanged: r.unchanged};
|
||||
}
|
||||
case 'unsubscribe': {
|
||||
const r = await ContactService.bulkUnsubscribe(projectId, ids);
|
||||
return {changed: r.updated, unchanged: r.unchanged};
|
||||
}
|
||||
case 'delete': {
|
||||
const r = await ContactService.bulkDelete(projectId, ids);
|
||||
return {changed: r.deleted, unchanged: 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBulkContactWorker() {
|
||||
const worker = new Worker<BulkContactActionJobData>(
|
||||
bulkContactQueue.name,
|
||||
async (job: Job<BulkContactActionJobData>) => {
|
||||
const {projectId, contactIds, operation} = job.data;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
||||
);
|
||||
const {projectId, operation, selector} = job.data;
|
||||
|
||||
const result: BulkActionResult = {
|
||||
operation,
|
||||
totalRequested: contactIds.length,
|
||||
totalRequested: 0,
|
||||
successCount: 0,
|
||||
unchangedCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Process contacts in batches
|
||||
if (selector.mode === 'ids') {
|
||||
const {contactIds} = selector;
|
||||
result.totalRequested = contactIds.length;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
||||
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||
result.successCount += changed;
|
||||
result.unchangedCount += unchanged;
|
||||
const failed = batchIds.length - changed - unchanged;
|
||||
if (failed > 0) result.failureCount += failed;
|
||||
} catch (error) {
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
email: '',
|
||||
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||
});
|
||||
}
|
||||
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
|
||||
}
|
||||
} else {
|
||||
const where = buildQueryWhere(projectId, selector);
|
||||
const total = await prisma.contact.count({where});
|
||||
result.totalRequested = total;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
|
||||
);
|
||||
|
||||
if (total === 0) {
|
||||
await job.updateProgress(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
|
||||
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
|
||||
// the rows we just processed disappear — a stable cursor would either skip survivors
|
||||
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
|
||||
let lastId: string | undefined;
|
||||
let processedRows = 0;
|
||||
|
||||
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
|
||||
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
|
||||
for (let iter = 0; iter < maxIterations; iter += 1) {
|
||||
const batch = await prisma.contact.findMany({
|
||||
where: {
|
||||
...where,
|
||||
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
|
||||
},
|
||||
select: {id: true},
|
||||
orderBy: {id: 'desc'},
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (batch.length === 0) break;
|
||||
|
||||
const batchIds = batch.map(c => c.id);
|
||||
lastId = batchIds[batchIds.length - 1];
|
||||
|
||||
try {
|
||||
let batchResult: {updated?: number; deleted?: number};
|
||||
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'delete':
|
||||
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
||||
result.successCount += batchResult.deleted || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// If some contacts in batch weren't processed, track them as failures
|
||||
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
||||
const failedCount = batchIds.length - processedCount;
|
||||
if (failedCount > 0) {
|
||||
result.failureCount += failedCount;
|
||||
// Note: We don't have individual contact details for batch failures
|
||||
}
|
||||
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||
result.successCount += changed;
|
||||
result.unchangedCount += unchanged;
|
||||
const failed = batchIds.length - changed - unchanged;
|
||||
if (failed > 0) result.failureCount += failed;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
processedRows += batchIds.length;
|
||||
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
|
||||
|
||||
if (batch.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
{
|
||||
connection: bulkContactQueue.opts.connection,
|
||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
||||
concurrency: 3,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||
*/
|
||||
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
@@ -82,6 +83,12 @@ export async function createEmailWorker() {
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelled emails are terminal for the campaign — finalize so it doesn't
|
||||
// stay stuck in SENDING forever waiting on emails that will never be sent.
|
||||
if (email.campaignId) {
|
||||
await CampaignService.finalizeIfDone(email.campaignId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,56 +233,8 @@ export async function createEmailWorker() {
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
await CampaignService.finalizeIfDone(email.campaignId);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
|
||||
@@ -222,11 +222,34 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Short Redis TTL for recent-count results. Short enough to feel live on the
|
||||
* dashboard live-pulse, long enough to absorb the polling load when many
|
||||
* tabs are open across the user base.
|
||||
*/
|
||||
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
|
||||
|
||||
/**
|
||||
* Get recent activity count (for real-time updates)
|
||||
* Returns count of activities in the last N minutes
|
||||
*
|
||||
* Backed by a short Redis cache because the dashboard polls this endpoint
|
||||
* every 30 seconds per open tab; without the cache, every poll would run
|
||||
* three COUNT queries against the events, emails, and workflow_executions
|
||||
* tables.
|
||||
*/
|
||||
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return parseInt(cached, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
||||
|
||||
@@ -245,7 +268,15 @@ export class ActivityService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return eventCount + emailCount + workflowCount;
|
||||
const total = eventCount + emailCount + workflowCount;
|
||||
|
||||
try {
|
||||
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
@@ -186,16 +186,26 @@ export class CampaignService {
|
||||
projectId: string,
|
||||
options: {
|
||||
status?: CampaignStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<PaginatedResponse<Campaign>> {
|
||||
const {status, page = 1, pageSize = 20} = options;
|
||||
const {status, search, page = 1, pageSize = 20} = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.CampaignWhereInput = {
|
||||
projectId,
|
||||
...(status ? {status} : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{name: {contains: search, mode: 'insensitive' as const}},
|
||||
{subject: {contains: search, mode: 'insensitive' as const}},
|
||||
{from: {contains: search, mode: 'insensitive' as const}},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [campaigns, total] = await Promise.all([
|
||||
@@ -511,45 +521,71 @@ export class CampaignService {
|
||||
// segment after totalRecipients was calculated are silently skipped. Without this
|
||||
// reconciliation, sentCount can never reach the original totalRecipients and the
|
||||
// campaign remains stuck in SENDING forever.
|
||||
const [actualEmailCount, alreadySentCount] = await Promise.all([
|
||||
prisma.email.count({where: {campaignId}}),
|
||||
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
|
||||
]);
|
||||
const actualEmailCount = await prisma.email.count({where: {campaignId}});
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: actualEmailCount},
|
||||
});
|
||||
|
||||
// If all emails were already processed by the time the last batch finished
|
||||
// (race: worker was faster than the batch chain), finalize the campaign now.
|
||||
if (actualEmailCount === 0 || alreadySentCount >= actualEmailCount) {
|
||||
const finalCampaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {project: {select: {name: true}}},
|
||||
});
|
||||
|
||||
if (finalCampaign && finalCampaign.status === CampaignStatus.SENDING) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.SENT, sentCount: alreadySentCount},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[CAMPAIGN] Campaign ${finalCampaign.name} finalized after last batch: ${alreadySentCount}/${actualEmailCount} emails sent`,
|
||||
);
|
||||
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
finalCampaign.name,
|
||||
finalCampaign.project.name,
|
||||
finalCampaign.projectId,
|
||||
alreadySentCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.finalizeIfDone(campaignId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a SENDING campaign if every email has reached a terminal state.
|
||||
* Terminal = sentAt is set OR status is FAILED. Counting FAILED as terminal
|
||||
* unsticks campaigns where some emails couldn't be delivered (e.g. the project
|
||||
* was disabled mid-send), so the campaign moves to SENT with a partial sentCount.
|
||||
*/
|
||||
public static async finalizeIfDone(campaignId: string): Promise<void> {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {select: {name: true}},
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign || campaign.status !== CampaignStatus.SENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [processedCount, sentCount] = await Promise.all([
|
||||
prisma.email.count({
|
||||
where: {
|
||||
campaignId,
|
||||
OR: [{sentAt: {not: null}}, {status: EmailStatus.FAILED}],
|
||||
},
|
||||
}),
|
||||
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
|
||||
]);
|
||||
|
||||
if (campaign.totalRecipients > 0 && processedCount < campaign.totalRecipients) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.SENT, sentCount},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[CAMPAIGN] Campaign ${campaign.name} finalized: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
sentCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a campaign
|
||||
*/
|
||||
|
||||
@@ -135,6 +135,47 @@ export class ContactService {
|
||||
* Update a contact
|
||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||
*/
|
||||
/**
|
||||
* Merge an incoming partial data object into existing contact data.
|
||||
* - `null` value on a key deletes that key
|
||||
* - empty strings are ignored
|
||||
* - reserved/system-generated keys are silently filtered
|
||||
* - `{value, persistent: false}` entries are skipped (non-persistent)
|
||||
*/
|
||||
private static mergeContactData(
|
||||
existing: Prisma.JsonValue | null,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> =
|
||||
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
|
||||
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (reservedFields.includes(key)) continue;
|
||||
if (value === '') continue;
|
||||
if (value === null) {
|
||||
delete merged[key];
|
||||
continue;
|
||||
}
|
||||
if (key === 'locale' && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
(value as {persistent: unknown}).persistent === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
public static async update(
|
||||
projectId: string,
|
||||
contactId: string,
|
||||
@@ -149,7 +190,14 @@ export class ContactService {
|
||||
updateData.email = data.email;
|
||||
}
|
||||
if (data.data !== undefined) {
|
||||
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
|
||||
if (data.data === null) {
|
||||
updateData.data = Prisma.JsonNull;
|
||||
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
|
||||
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
|
||||
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
|
||||
} else {
|
||||
throw new HttpException(400, 'data must be an object');
|
||||
}
|
||||
}
|
||||
if (data.subscribed !== undefined) {
|
||||
updateData.subscribed = data.subscribed;
|
||||
@@ -225,69 +273,7 @@ export class ContactService {
|
||||
},
|
||||
});
|
||||
|
||||
// Process data to merge with existing data
|
||||
let mergedData: Record<string, unknown> = {};
|
||||
|
||||
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
|
||||
// Start with existing data
|
||||
mergedData = {...existing.data};
|
||||
}
|
||||
|
||||
// Merge new data (if provided)
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty string values - they don't provide meaningful data
|
||||
// and can cause issues with template rendering and data integrity
|
||||
if (value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete field if null is passed (allows removing fields from contact data)
|
||||
if (value === null) {
|
||||
delete mergedData[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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 (value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
value.persistent === false
|
||||
) {
|
||||
// Non-persistent fields are not stored in contact data
|
||||
// They would be used only for the current operation (like email template rendering)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the value
|
||||
mergedData[key] = value;
|
||||
}
|
||||
}
|
||||
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
|
||||
|
||||
if (existing) {
|
||||
// Track subscription status change
|
||||
@@ -721,99 +707,81 @@ export class ContactService {
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
* Updates multiple contacts to subscribed=true in batches.
|
||||
* `updated` = contacts flipped from unsubscribed to subscribed.
|
||||
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
|
||||
*/
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
public static async bulkSubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number; unchanged: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
where: {id: {in: contactIds}, projectId},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
if (contacts.length === 0) {
|
||||
return {updated: 0, unchanged: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently unsubscribed
|
||||
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||
const unchanged = contacts.length - unsubscribedIds.length;
|
||||
|
||||
if (unsubscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
return {updated: 0, unchanged};
|
||||
}
|
||||
|
||||
// Update in a single query for performance
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: unsubscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: true,
|
||||
},
|
||||
where: {id: {in: unsubscribedIds}, projectId},
|
||||
data: {subscribed: true},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
return {updated: result.count, unchanged};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
* Bulk unsubscribe contacts.
|
||||
* `updated` = contacts flipped from subscribed to unsubscribed.
|
||||
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
|
||||
*/
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
public static async bulkUnsubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number; unchanged: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
where: {id: {in: contactIds}, projectId},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
if (contacts.length === 0) {
|
||||
return {updated: 0, unchanged: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently subscribed
|
||||
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||
const unchanged = contacts.length - subscribedIds.length;
|
||||
|
||||
if (subscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
return {updated: 0, unchanged};
|
||||
}
|
||||
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: subscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: false,
|
||||
},
|
||||
where: {id: {in: subscribedIds}, projectId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
return {updated: result.count, unchanged};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {CampaignStatus, EmailStatus} from '@plunk/db';
|
||||
import {type Job, Queue} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
import type {
|
||||
ApiRequestCleanupJobData,
|
||||
BulkContactActionJobData,
|
||||
BulkContactActionSelector,
|
||||
CampaignBatchJobData,
|
||||
ContactImportJobData,
|
||||
DomainVerificationJobData,
|
||||
@@ -349,12 +351,12 @@ export class QueueService {
|
||||
*/
|
||||
public static async queueBulkContactAction(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
selector: BulkContactActionSelector,
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
): Promise<Job<BulkContactActionJobData>> {
|
||||
return bulkContactQueue.add(
|
||||
'bulk-contact-action',
|
||||
{projectId, contactIds, operation},
|
||||
{projectId, operation, selector},
|
||||
{
|
||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||
},
|
||||
@@ -578,6 +580,39 @@ export class QueueService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark every still-PENDING email for this project as FAILED. We just stripped
|
||||
// their queue jobs, so without this they'd sit as PENDING forever and any
|
||||
// campaign waiting on them would stay stuck in SENDING.
|
||||
const failed = await prisma.email.updateMany({
|
||||
where: {projectId, status: EmailStatus.PENDING},
|
||||
data: {status: EmailStatus.FAILED, error: 'Project is disabled'},
|
||||
});
|
||||
|
||||
if (failed.count > 0) {
|
||||
signale.info(`[QUEUE] Marked ${failed.count} pending emails as failed for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Finalize any in-flight campaigns. With the orphaned PENDING emails now FAILED
|
||||
// (terminal), the campaign can move to SENT with a partial sentCount instead of
|
||||
// staying stuck in SENDING. Reconcile totalRecipients first since the batch
|
||||
// chain may have been cut short.
|
||||
const sendingCampaigns = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.SENDING},
|
||||
select: {id: true},
|
||||
});
|
||||
|
||||
if (sendingCampaigns.length > 0) {
|
||||
const {CampaignService} = await import('./CampaignService.js');
|
||||
for (const campaign of sendingCampaigns) {
|
||||
const actualEmailCount = await prisma.email.count({where: {campaignId: campaign.id}});
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {totalRecipients: actualEmailCount},
|
||||
});
|
||||
await CampaignService.finalizeIfDone(campaign.id);
|
||||
}
|
||||
}
|
||||
|
||||
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AWS_SES_REGION,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
@@ -250,10 +251,13 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
|
||||
// Verify DKIM for the domain
|
||||
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
||||
|
||||
// Set custom MAIL FROM domain (plunk.yourdomain.com)
|
||||
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be
|
||||
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
|
||||
// is already in use for something else (e.g., a CNAME to a CDN), since the
|
||||
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: domain,
|
||||
MailFromDomain: `plunk.${domain}`,
|
||||
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens ?? [];
|
||||
|
||||
@@ -979,7 +979,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||
|
||||
const contact = execution.contact;
|
||||
const currentData =
|
||||
@@ -987,24 +987,43 @@ export class WorkflowExecutionService {
|
||||
? (contact.data as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Merge updates with current data
|
||||
const newData = {
|
||||
...currentData,
|
||||
...updates,
|
||||
};
|
||||
const hasDataUpdates = updates && Object.keys(updates).length > 0;
|
||||
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
|
||||
|
||||
// Update contact in database
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: {
|
||||
data: newData ? toPrismaJson(newData) : undefined,
|
||||
},
|
||||
});
|
||||
const desiredSubscribed =
|
||||
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
|
||||
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
|
||||
|
||||
const updateData: Prisma.ContactUpdateInput = {};
|
||||
if (hasDataUpdates) {
|
||||
updateData.data = toPrismaJson(newData);
|
||||
}
|
||||
if (subscriptionChanging) {
|
||||
updateData.subscribed = desiredSubscribed;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
if (subscriptionChanging) {
|
||||
const {EventService} = await import('./EventService.js');
|
||||
await EventService.trackEvent(
|
||||
execution.workflow.projectId,
|
||||
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
|
||||
contact.id,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
updated: hasDataUpdates || subscriptionChanging,
|
||||
updates,
|
||||
newData,
|
||||
subscriptionAction,
|
||||
subscribed: desiredSubscribed ?? contact.subscribed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,72 @@ export class WorkflowService {
|
||||
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow including all steps and transitions.
|
||||
* The duplicate always starts disabled to prevent accidental triggering.
|
||||
* Runtime execution state is intentionally not copied.
|
||||
*/
|
||||
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
|
||||
const source = await this.get(projectId, workflowId);
|
||||
|
||||
const transitions = await prisma.workflowTransition.findMany({
|
||||
where: {fromStep: {workflowId}},
|
||||
});
|
||||
|
||||
return prisma.$transaction(async tx => {
|
||||
const newWorkflow = await tx.workflow.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${source.name} (Copy)`,
|
||||
description: source.description,
|
||||
triggerType: source.triggerType,
|
||||
triggerConfig:
|
||||
source.triggerConfig === null
|
||||
? Prisma.JsonNull
|
||||
: (source.triggerConfig as Prisma.InputJsonValue),
|
||||
enabled: false,
|
||||
allowReentry: source.allowReentry,
|
||||
},
|
||||
});
|
||||
|
||||
const stepIdMap = new Map<string, string>();
|
||||
|
||||
for (const step of source.steps) {
|
||||
const created = await tx.workflowStep.create({
|
||||
data: {
|
||||
workflowId: newWorkflow.id,
|
||||
type: step.type,
|
||||
name: step.name,
|
||||
position: step.position as Prisma.InputJsonValue,
|
||||
config: step.config as Prisma.InputJsonValue,
|
||||
templateId: step.templateId,
|
||||
},
|
||||
});
|
||||
stepIdMap.set(step.id, created.id);
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
const fromStepId = stepIdMap.get(transition.fromStepId);
|
||||
const toStepId = stepIdMap.get(transition.toStepId);
|
||||
if (!fromStepId || !toStepId) continue;
|
||||
|
||||
await tx.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId,
|
||||
toStepId,
|
||||
condition:
|
||||
transition.condition === null
|
||||
? Prisma.JsonNull
|
||||
: (transition.condition as Prisma.InputJsonValue),
|
||||
priority: transition.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return newWorkflow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a step to a workflow
|
||||
*/
|
||||
|
||||
@@ -53,6 +53,9 @@ export const Keys = {
|
||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||
},
|
||||
recentCount(projectId: string, minutes: number): string {
|
||||
return `activity:recent-count:${projectId}:${minutes}`;
|
||||
},
|
||||
},
|
||||
Analytics: {
|
||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Plunk
|
||||
|
||||
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
|
||||
|
||||
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
|
||||
|
||||
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
|
||||
|
||||
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
|
||||
|
||||
## Product
|
||||
|
||||
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
|
||||
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
|
||||
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
|
||||
|
||||
## Features
|
||||
|
||||
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
|
||||
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
|
||||
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
|
||||
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
|
||||
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
|
||||
|
||||
## Comparisons
|
||||
|
||||
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
|
||||
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
|
||||
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
|
||||
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
|
||||
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
|
||||
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
|
||||
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
|
||||
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
|
||||
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
|
||||
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
|
||||
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
|
||||
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
|
||||
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
|
||||
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
|
||||
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
|
||||
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
|
||||
- [All comparisons](https://www.useplunk.com/vs)
|
||||
|
||||
## Guides
|
||||
|
||||
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
|
||||
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
|
||||
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
|
||||
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
|
||||
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
|
||||
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
|
||||
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
|
||||
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
|
||||
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
|
||||
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
|
||||
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
|
||||
- [All guides](https://www.useplunk.com/guides)
|
||||
|
||||
## Tools
|
||||
|
||||
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
|
||||
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
|
||||
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
|
||||
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
|
||||
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
|
||||
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
|
||||
- [MX checker](https://www.useplunk.com/tools/mx-checker)
|
||||
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
|
||||
- [All tools](https://www.useplunk.com/tools)
|
||||
|
||||
## Optional
|
||||
|
||||
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
|
||||
- [Discord community](https://www.useplunk.com/discord)
|
||||
- [Privacy policy](https://www.useplunk.com/privacy)
|
||||
- [Terms of service](https://www.useplunk.com/terms)
|
||||
- [Data processing agreement](https://www.useplunk.com/dpa)
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {WIKI_URI} from '../../lib/constants';
|
||||
import logo from '../../../public/assets/logo.svg';
|
||||
|
||||
@@ -7,6 +8,10 @@ import logo from '../../../public/assets/logo.svg';
|
||||
*
|
||||
*/
|
||||
export default function Footer() {
|
||||
const router = useRouter();
|
||||
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
|
||||
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const mdHref = `${trimmed}.md`;
|
||||
return (
|
||||
<>
|
||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||
@@ -87,6 +92,25 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Checkers</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/tools/spf-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
SPF checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dmarc-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DMARC checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dkim-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DKIM checker
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -244,8 +268,15 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
||||
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
|
||||
Reading this with electronic eyes? Append{' '}
|
||||
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
|
||||
<code>.md</code>
|
||||
</a>{' '}
|
||||
to any URL for the Markdown cut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {ArrowRight, Gift, X} from 'lucide-react';
|
||||
import {DASHBOARD_URI} from '../lib/constants';
|
||||
|
||||
interface SwitchOfferProps {
|
||||
competitorName: string;
|
||||
}
|
||||
|
||||
export function SwitchOffer({competitorName}: SwitchOfferProps) {
|
||||
const storageKey = `switch-offer-dismissed:${competitorName}`;
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.localStorage.getItem(storageKey) === '1') return;
|
||||
const t = window.setTimeout(() => setVisible(true), 800);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [storageKey]);
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(storageKey, '1');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 24, scale: 0.96}}
|
||||
animate={{opacity: 1, y: 0, scale: 1}}
|
||||
exit={{opacity: 0, y: 16, scale: 0.97}}
|
||||
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
|
||||
}
|
||||
role="dialog"
|
||||
aria-label={`Switch from ${competitorName} to Plunk offer`}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss offer"
|
||||
className={
|
||||
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
|
||||
<div
|
||||
className={
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
|
||||
}
|
||||
>
|
||||
<Gift className="h-3 w-3 text-neutral-700" />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
|
||||
>
|
||||
Switching offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
Switching from {competitorName}?
|
||||
<br />
|
||||
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
|
||||
</h3>
|
||||
|
||||
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
|
||||
Sign up and enter this code at checkout to redeem 2,000 email credits.
|
||||
</p>
|
||||
|
||||
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
|
||||
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
|
||||
>
|
||||
SWITCH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Claim your credits
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './ComparisonTable';
|
||||
export * from './FAQSection';
|
||||
export * from './CodeBlock';
|
||||
export * from './SectionHeader';
|
||||
export * from './SwitchOffer';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
|
||||
'index',
|
||||
'pricing',
|
||||
'features/workflows',
|
||||
'features/segments',
|
||||
'features/inbound-email',
|
||||
'features/email-editor',
|
||||
'features/smtp',
|
||||
]);
|
||||
|
||||
export function hasMarkdownVariant(pathname: string): boolean {
|
||||
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||
return MARKDOWN_SLUGS.has(slug);
|
||||
}
|
||||
@@ -341,3 +341,5 @@ Plunk provides SMTP credentials so you can send emails from any application or f
|
||||
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||
`,
|
||||
};
|
||||
|
||||
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { hasMarkdownVariant } from './content/markdown-slugs';
|
||||
|
||||
type Negotiated = 'markdown' | 'html' | 'none';
|
||||
|
||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||
@@ -59,6 +61,9 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('Vary', 'Accept');
|
||||
if (hasMarkdownVariant(pathname)) {
|
||||
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@ import '../styles/globals.css';
|
||||
import React, {useEffect} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {AppProps} from 'next/app';
|
||||
import {useRouter} from 'next/router';
|
||||
import {toast, Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {network} from '../lib/network';
|
||||
import {hasMarkdownVariant} from '../content/markdown-slugs';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import Script from 'next/script';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
@@ -37,6 +39,10 @@ const mono = JetBrains_Mono({
|
||||
* @param props.pageProps
|
||||
*/
|
||||
function App({Component, pageProps}: AppProps) {
|
||||
const router = useRouter();
|
||||
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
|
||||
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const message = searchParams.get('message');
|
||||
@@ -51,6 +57,7 @@ function App({Component, pageProps}: AppProps) {
|
||||
<Head>
|
||||
<title>Plunk | The Open-Source Email Platform</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
||||
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
|
||||
</Head>
|
||||
<Toaster position={'top-right'} />
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ export default function Index() {
|
||||
<Link
|
||||
href={`/vs/${c.slug}`}
|
||||
className={
|
||||
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
|
||||
'group flex items-center justify-between gap-6 py-5 transition-colors hover:bg-neutral-50 sm:py-7'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center gap-6 sm:gap-10'}>
|
||||
@@ -481,28 +481,25 @@ export default function Index() {
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={
|
||||
'text-4xl font-bold tracking-[-0.03em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-6xl lg:text-7xl'
|
||||
'text-3xl font-semibold tracking-[-0.025em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-4xl lg:text-5xl'
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-4'}>
|
||||
<div className={'flex items-center gap-5'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={
|
||||
'hidden text-xs uppercase tracking-[0.18em] text-neutral-500 transition group-hover:text-neutral-900 sm:inline'
|
||||
'hidden text-[11px] uppercase tracking-[0.18em] text-neutral-400 transition group-hover:text-neutral-900 sm:inline'
|
||||
}
|
||||
>
|
||||
vs Plunk
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 text-neutral-900 transition group-hover:border-neutral-900 group-hover:bg-neutral-900 group-hover:text-white sm:h-14 sm:w-14'
|
||||
}
|
||||
>
|
||||
<ArrowUpRight className={'h-5 w-5'} strokeWidth={2} />
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className={'h-5 w-5 text-neutral-400 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-neutral-900 sm:h-6 sm:w-6'}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.li>
|
||||
@@ -516,8 +513,8 @@ export default function Index() {
|
||||
<SectionHeader
|
||||
number={'03'}
|
||||
label={'The Problem'}
|
||||
title={"Most email tools weren't built to scale."}
|
||||
subtitle={'Three things Plunk gets right, out of the box.'}
|
||||
title={'Simple to start. Serious at scale.'}
|
||||
subtitle={'Easy enough for a side project. Ready for the business it becomes.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-20 lg:gap-28'}>
|
||||
@@ -580,7 +577,7 @@ export default function Index() {
|
||||
subtitle={'Every Plunk install ships with the full platform — no upsell pages, no locked modules.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:auto-rows-[17rem] lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => {
|
||||
const highlighted = feature.feature;
|
||||
return (
|
||||
@@ -592,12 +589,14 @@ export default function Index() {
|
||||
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
highlighted
|
||||
? 'flex min-h-72 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
|
||||
? 'flex min-h-80 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-900 bg-neutral-900 p-10 text-white sm:col-span-2 lg:row-span-2 lg:p-12'
|
||||
: 'flex min-h-72 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
|
||||
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>
|
||||
{highlighted ? <Workflow className="h-10 w-10" strokeWidth={1.25} /> : feature.icon}
|
||||
</div>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={`text-[11px] uppercase tracking-[0.18em] ${
|
||||
@@ -610,16 +609,20 @@ export default function Index() {
|
||||
<div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={`mt-10 text-2xl font-bold tracking-[-0.025em] ${
|
||||
highlighted ? 'text-white' : 'text-neutral-900'
|
||||
}`}
|
||||
className={
|
||||
highlighted
|
||||
? 'text-4xl font-extrabold leading-[0.95] tracking-[-0.035em] text-white sm:text-5xl lg:text-6xl'
|
||||
: 'mt-10 text-2xl font-bold tracking-[-0.025em] text-neutral-900'
|
||||
}
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-3 text-sm leading-relaxed ${
|
||||
highlighted ? 'text-neutral-300' : 'text-neutral-600'
|
||||
}`}
|
||||
className={
|
||||
highlighted
|
||||
? 'mt-5 max-w-md text-base leading-relaxed text-neutral-300 sm:text-lg'
|
||||
: 'mt-3 text-sm leading-relaxed text-neutral-600'
|
||||
}
|
||||
>
|
||||
{feature.description}
|
||||
</p>
|
||||
@@ -637,60 +640,73 @@ export default function Index() {
|
||||
number={'05'}
|
||||
label={'Data Model'}
|
||||
title={'One contact,'}
|
||||
titleAccent={'complete history.'}
|
||||
titleAccent={'complete history.'}
|
||||
subtitle={
|
||||
'Every interaction flows into a single contact record. Transactional, campaign and workflow events — one source of truth.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto mt-20 max-w-5xl'}>
|
||||
<div className={'grid gap-6 lg:grid-cols-3'}>
|
||||
<div className={'mx-auto mt-20 max-w-3xl'}>
|
||||
<div className={'grid gap-4 sm:grid-cols-2'}>
|
||||
{[
|
||||
{
|
||||
icon: <Send className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Send className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Transactional',
|
||||
sub: 'Receipts, resets',
|
||||
},
|
||||
{
|
||||
icon: <Megaphone className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Megaphone className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Campaigns',
|
||||
sub: 'Newsletters, launches',
|
||||
},
|
||||
{
|
||||
icon: <Workflow className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Workflow className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Workflows',
|
||||
sub: 'Onboarding, drip sequences',
|
||||
},
|
||||
{
|
||||
icon: <Inbox className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Inbound',
|
||||
sub: 'Replies, support',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1 + i * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-[24px] border border-neutral-200 bg-white p-8 text-left'}
|
||||
transition={{duration: 0.5, delay: 0.1 + i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex items-center gap-4 rounded-2xl border border-neutral-200 bg-white p-5'}
|
||||
>
|
||||
<div className={'text-neutral-900'}>{item.icon}</div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
<div
|
||||
className={
|
||||
'flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-100 text-neutral-900'
|
||||
}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>{item.sub}</p>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className={'min-w-0'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-base font-semibold tracking-[-0.01em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-0.5 text-xs text-neutral-600'}>{item.sub}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={'relative my-12 hidden lg:block'}>
|
||||
<svg className={'mx-auto h-28 w-full'} viewBox="0 0 600 120" preserveAspectRatio="none">
|
||||
<div className={'relative my-10 hidden justify-center sm:flex'}>
|
||||
<svg width="40" height="80" viewBox="0 0 40 80" aria-hidden>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#a3a3a3" />
|
||||
</marker>
|
||||
</defs>
|
||||
<motion.path
|
||||
d="M 100 0 Q 150 60, 250 110"
|
||||
d="M 20 0 L 20 70"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
@@ -698,29 +714,7 @@ export default function Index() {
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 300 0 L 300 110"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 500 0 Q 450 60, 350 110"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||
transition={{duration: 0.9, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -845,7 +839,7 @@ export default function Index() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={'mt-12 flex justify-center'}>
|
||||
<div className={'mt-10 flex justify-center'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
@@ -962,7 +956,7 @@ export default function Index() {
|
||||
subtitle={'No hyperbole. Just the people building products on Plunk.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:auto-rows-[17rem] lg:grid-cols-3'}>
|
||||
{testimonials.map((t, i) => {
|
||||
const highlighted = t.featured;
|
||||
return (
|
||||
@@ -974,7 +968,7 @@ export default function Index() {
|
||||
transition={{duration: 0.5, delay: i * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
highlighted
|
||||
? 'flex min-h-72 flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
|
||||
? 'flex min-h-80 flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-10 text-white sm:col-span-2 lg:row-span-2 lg:p-12'
|
||||
: 'flex min-h-72 flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8'
|
||||
}
|
||||
>
|
||||
@@ -982,28 +976,34 @@ export default function Index() {
|
||||
style={highlighted ? {fontFamily: 'var(--font-display)'} : undefined}
|
||||
className={
|
||||
highlighted
|
||||
? 'text-xl font-medium leading-[1.2] tracking-[-0.015em] text-white sm:text-2xl'
|
||||
? 'text-3xl font-medium leading-[1.1] tracking-[-0.02em] text-white sm:text-4xl lg:text-5xl'
|
||||
: 'text-sm leading-relaxed text-neutral-700'
|
||||
}
|
||||
>
|
||||
“{t.testimonial}”
|
||||
</blockquote>
|
||||
<figcaption className={'mt-6 flex items-center gap-3'}>
|
||||
<figcaption className={`mt-6 flex items-center ${highlighted ? 'gap-4' : 'gap-3'}`}>
|
||||
<div
|
||||
className={`relative h-10 w-10 overflow-hidden rounded-full ${
|
||||
highlighted ? 'border border-neutral-700' : ''
|
||||
className={`relative overflow-hidden rounded-full ${
|
||||
highlighted ? 'h-12 w-12 border border-neutral-700' : 'h-10 w-10'
|
||||
}`}
|
||||
>
|
||||
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-semibold ${highlighted ? 'text-white' : 'text-neutral-900'}`}>
|
||||
<div
|
||||
className={
|
||||
highlighted
|
||||
? 'text-sm font-semibold text-white'
|
||||
: 'text-xs font-semibold text-neutral-900'
|
||||
}
|
||||
>
|
||||
{t.author}
|
||||
</div>
|
||||
<div
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={`mt-0.5 text-[10px] uppercase tracking-[0.18em] ${
|
||||
highlighted ? 'text-neutral-400' : 'text-neutral-500'
|
||||
className={`mt-0.5 uppercase tracking-[0.18em] ${
|
||||
highlighted ? 'text-[11px] text-neutral-400' : 'text-[10px] text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{t.role}
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Key, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
const COMMON_SELECTORS = [
|
||||
{label: 'Google Workspace', value: 'google'},
|
||||
{label: 'Microsoft 365', value: 'selector1'},
|
||||
{label: 'Microsoft 365 (2)', value: 'selector2'},
|
||||
{label: 'Mailchimp', value: 'k1'},
|
||||
{label: 'Mailchimp (2)', value: 'k2'},
|
||||
{label: 'Postmark', value: 'pm'},
|
||||
{label: 'SendGrid', value: 'sendgrid'},
|
||||
{label: 'Amazon SES', value: 'ses'},
|
||||
{label: 'Mailjet', value: 'mailjet'},
|
||||
{label: 'Zoho', value: 'zoho'},
|
||||
{label: 'Generic', value: 'mail'},
|
||||
{label: 'Generic (2)', value: 'default'},
|
||||
{label: 'Generic (3)', value: 's1'},
|
||||
{label: 'Generic (4)', value: 's2'},
|
||||
];
|
||||
|
||||
interface DkimIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DkimAnalysis {
|
||||
keyType: string;
|
||||
isRevoked: boolean;
|
||||
isTesting: boolean;
|
||||
publicKeySnippet: string;
|
||||
issues: DkimIssue[];
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
function analyzeDkim(tags: Record<string, string>): DkimAnalysis {
|
||||
const keyType = tags['k'] ?? 'rsa';
|
||||
const publicKey = tags['p'] ?? '';
|
||||
const flags = tags['t'] ?? '';
|
||||
const isRevoked = publicKey === '';
|
||||
const isTesting = flags.includes('y');
|
||||
const issues: DkimIssue[] = [];
|
||||
|
||||
if (isRevoked) {
|
||||
issues.push({type: 'error', label: 'DKIM key has been revoked', detail: 'p= is empty, which signals that this key has been intentionally revoked. Email signed with this selector will fail DKIM validation. Publish a new key.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Public key is present', detail: 'A valid public key is published for this selector.'});
|
||||
}
|
||||
|
||||
if (isTesting) {
|
||||
issues.push({type: 'warning', label: 'Key is in testing mode (t=y)', detail: 'Testing mode means receiving servers should not reject messages that fail DKIM, even if the signature is invalid. Remove t=y to enable full enforcement.'});
|
||||
}
|
||||
|
||||
if (keyType === 'rsa') {
|
||||
issues.push({type: 'pass', label: 'Key type: RSA', detail: 'RSA is the standard and widely-supported DKIM key type.'});
|
||||
} else if (keyType === 'ed25519') {
|
||||
issues.push({type: 'pass', label: 'Key type: Ed25519', detail: 'Ed25519 provides strong security with smaller key sizes. Ensure your sending infrastructure supports it, as some older servers may not.'});
|
||||
}
|
||||
|
||||
const publicKeySnippet = publicKey.length > 32 ? `${publicKey.slice(0, 32)}…` : publicKey;
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {keyType, isRevoked, isTesting, publicKeySnippet, issues, grade};
|
||||
}
|
||||
|
||||
function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'DKIM key is active'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'DKIM has configuration issues'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Invalid', sub: 'DKIM key is revoked or invalid'},
|
||||
};
|
||||
const {cls, label, sub} = map[grade];
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
v: 'DKIM version',
|
||||
k: 'Key type (rsa or ed25519)',
|
||||
p: 'Base64-encoded public key',
|
||||
t: 'Flags (y=testing, s=strict service)',
|
||||
s: 'Service type restriction',
|
||||
h: 'Acceptable hash algorithms',
|
||||
n: 'Notes (human-readable)',
|
||||
};
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DKIM?',
|
||||
answer: 'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a digital signature to outgoing email. The signature is verified by receiving servers using a public key published in your DNS. If the signature matches, the email is confirmed to have originated from your domain and has not been tampered with in transit.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DKIM selector?',
|
||||
answer: 'A DKIM selector is a label that identifies which DKIM key to use when there are multiple keys for a domain. Selectors are arbitrary strings chosen by the sending service (e.g., "google" for Google Workspace, "selector1" for Microsoft 365, "k1" for Mailchimp). The DKIM record is published at {selector}._domainkey.{domain}.',
|
||||
},
|
||||
{
|
||||
question: 'Where do I find my DKIM selector?',
|
||||
answer: 'Your DKIM selector is provided by your email sending service. In Google Workspace, it\'s typically "google". In Microsoft 365, it\'s "selector1" and "selector2". In Mailchimp, it\'s "k1". Check your email provider\'s DNS setup guide or look in the DKIM signature of a sent email (the "s=" tag in the DKIM-Signature header).',
|
||||
},
|
||||
{
|
||||
question: 'Why is my DKIM key revoked?',
|
||||
answer: 'A DKIM key is revoked by publishing a DKIM record with an empty p= value. This is intentional and signals that the key should no longer be used. Reasons include key rotation, key compromise, or switching email providers. If you didn\'t intentionally revoke the key, check your DNS records and publish a new DKIM key.',
|
||||
},
|
||||
{
|
||||
question: 'Should I use RSA or Ed25519 for DKIM?',
|
||||
answer: 'RSA (2048-bit) is the safest choice for maximum compatibility, as it is supported by all email providers. Ed25519 offers equivalent security with much smaller keys but is not supported by some older mail servers. A best practice is to publish both an RSA key and an Ed25519 key with different selectors, letting modern servers prefer Ed25519.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface DkimLookupResult {
|
||||
domain: string;
|
||||
selector: string;
|
||||
found: boolean;
|
||||
record: string | null;
|
||||
tags: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDkim(domain: string, selector: string): Promise<DkimLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
const sel = selector.trim().toLowerCase();
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`${sel}._domainkey.${clean}`)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? [])
|
||||
.map((a: DnsAnswer) => cleanTxt(a.data))
|
||||
.filter(r => r.startsWith('v=DKIM1') || r.includes('k=') || r.includes('p='));
|
||||
if (records.length === 0) return {domain: clean, selector: sel, found: false, record: null, tags: {}};
|
||||
const record: string = records[0]!;
|
||||
const tags: Record<string, string> = {};
|
||||
record.split(';').forEach(part => {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = part.slice(0, eqIdx).trim();
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
if (key) tags[key] = value;
|
||||
}
|
||||
});
|
||||
return {domain: clean, selector: sel, found: true, record, tags};
|
||||
} catch {
|
||||
return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function DkimCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [selector, setSelector] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DkimLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DkimAnalysis | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setAnalysis(null);
|
||||
|
||||
try {
|
||||
const data = await lookupDkim(domain, selector);
|
||||
setResult(data);
|
||||
if (data.found && data.tags) {
|
||||
setAnalysis(analyzeDkim(data.tags));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DKIM Record Checker | Free DKIM Lookup & Validator | Plunk"
|
||||
description="Free DKIM record checker. Look up your DKIM public key by domain and selector, validate the record, and get advice to fix misconfigurations."
|
||||
canonical="https://www.useplunk.com/tools/dkim-checker"
|
||||
openGraph={{
|
||||
title: 'DKIM Record Checker | Free DKIM Lookup & Validator | Plunk',
|
||||
description: 'Free DKIM record checker. Look up and validate your DKIM key by domain and selector.',
|
||||
url: 'https://www.useplunk.com/tools/dkim-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DKIM+Record+Checker&tag=Tool', alt: 'Plunk DKIM Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-06 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DKIM record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up your domain's DKIM public key by selector. Verify the key is active, understand the
|
||||
configuration, and get advice if something looks wrong.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Key className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DKIM record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="selector" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
DKIM selector <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="selector"
|
||||
type="text"
|
||||
value={selector}
|
||||
onChange={e => setSelector(e.target.value)}
|
||||
placeholder="google"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>
|
||||
Not sure? Try a common selector below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Common selectors */}
|
||||
<div>
|
||||
<p className={'mb-2 text-xs font-medium text-neutral-500'}>Common selectors</p>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
{COMMON_SELECTORS.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => setSelector(s.value)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
selector === s.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{s.label} <span style={{fontFamily: 'var(--font-mono)'}} className={'opacity-60'}>({s.value})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Key className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DKIM record…' : 'Check DKIM record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-amber-100 bg-amber-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-600'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-amber-900'}>No DKIM record found</p>
|
||||
<p className={'mt-1 text-sm text-amber-800'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No DKIM record was found at `}
|
||||
{!result.error && (
|
||||
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-xs'}>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
)}
|
||||
</p>
|
||||
{!result.error && (
|
||||
<p className={'mt-2 text-sm text-amber-700'}>
|
||||
Check that you are using the correct selector. If your email provider has given you a specific selector, use that. If the record still doesn't appear, DNS propagation may still be in progress.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Lookup host */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Record found at
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-sm font-medium text-neutral-700'}
|
||||
>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
|
||||
{result.record && (
|
||||
<>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 mt-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record.length > 200
|
||||
? `${result.record.slice(0, 200)}… [${result.record.length - 200} more characters]`
|
||||
: result.record}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{Object.keys(result.tags).length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => {
|
||||
const displayValue = key === 'p' && value.length > 48
|
||||
? `${value.slice(0, 48)}… [${value.length - 48} more chars]`
|
||||
: value || '(empty — key revoked)';
|
||||
return (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 flex-col gap-0.5'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DKIM explained'}
|
||||
title={'How DKIM signing works.'}
|
||||
subtitle={'DKIM proves your email was sent by your domain and wasn\'t altered in transit.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Cryptographic signature',
|
||||
body: 'Your sending server signs each email using a private key. The corresponding public key is published in DNS. Receiving servers verify the signature to confirm authenticity.',
|
||||
},
|
||||
{
|
||||
title: 'Selector system',
|
||||
body: 'Each DKIM key is identified by a selector. You can have multiple selectors (and keys) per domain, allowing key rotation and multiple sending providers at the same time.',
|
||||
},
|
||||
{
|
||||
title: 'Tamper detection',
|
||||
body: 'The DKIM signature covers specific email headers and the body. If the email is modified in transit, the signature breaks and DKIM fails — protecting against content manipulation.',
|
||||
},
|
||||
{
|
||||
title: 'Key rotation',
|
||||
body: 'Best practice is to rotate DKIM keys annually. Publish the new key under a different selector, update your sending infrastructure, then revoke the old key by setting p= to empty.',
|
||||
},
|
||||
{
|
||||
title: 'DKIM alone is not enough',
|
||||
body: 'Like SPF, DKIM authentication alone doesn\'t protect the visible From header. You need DMARC to enforce authentication policies and protect against spoofing.',
|
||||
},
|
||||
{
|
||||
title: '2048-bit RSA minimum',
|
||||
body: '1024-bit RSA keys are considered insecure. Use at least 2048-bit RSA or switch to Ed25519, which provides equivalent security with much smaller keys.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Sign every email. Reach the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk configures DKIM signing automatically and guides you through setting up SPF and DMARC for your
|
||||
domain.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dkim"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DKIM?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dkim-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, ShieldCheck, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface DmarcIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DmarcAnalysis {
|
||||
policy: string;
|
||||
subPolicy: string | null;
|
||||
pct: number;
|
||||
hasRua: boolean;
|
||||
hasRuf: boolean;
|
||||
adkim: string;
|
||||
aspf: string;
|
||||
issues: DmarcIssue[];
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
function analyzeDmarc(tags: Record<string, string>): DmarcAnalysis {
|
||||
const policy = tags['p'] ?? '';
|
||||
const subPolicy = tags['sp'] ?? null;
|
||||
const pct = parseInt(tags['pct'] ?? '100', 10);
|
||||
const hasRua = Boolean(tags['rua']);
|
||||
const hasRuf = Boolean(tags['ruf']);
|
||||
const adkim = tags['adkim'] ?? 'r';
|
||||
const aspf = tags['aspf'] ?? 'r';
|
||||
const issues: DmarcIssue[] = [];
|
||||
|
||||
// Policy
|
||||
if (!policy) {
|
||||
issues.push({type: 'error', label: 'Missing policy (p=)', detail: 'The p= tag is required. Set p=none to monitor, p=quarantine to send to spam, or p=reject to block.'});
|
||||
} else if (policy === 'none') {
|
||||
issues.push({type: 'warning', label: 'Policy is p=none (monitoring only)', detail: 'p=none means DMARC failures are reported but no action is taken. Upgrade to p=quarantine then p=reject once you confirm legitimate mail passes.'});
|
||||
} else if (policy === 'quarantine') {
|
||||
issues.push({type: 'warning', label: 'Policy is p=quarantine', detail: 'Failing messages are sent to spam/junk. Consider upgrading to p=reject for full protection.'});
|
||||
} else if (policy === 'reject') {
|
||||
issues.push({type: 'pass', label: 'Policy is p=reject — maximum protection', detail: 'Failing messages are rejected outright. This is the strongest DMARC policy.'});
|
||||
}
|
||||
|
||||
// Percentage
|
||||
if (pct < 100) {
|
||||
issues.push({type: 'warning', label: `Policy applies to only ${pct}% of messages`, detail: `pct=${pct} means DMARC enforcement only applies to ${pct}% of failing mail. Set pct=100 for full enforcement.`});
|
||||
} else if (policy && policy !== 'none') {
|
||||
issues.push({type: 'pass', label: 'Policy applies to 100% of messages', detail: 'DMARC enforcement is fully deployed.'});
|
||||
}
|
||||
|
||||
// Aggregate reports
|
||||
if (!hasRua) {
|
||||
issues.push({type: 'warning', label: 'No aggregate reporting (rua= missing)', detail: 'Without rua=, you receive no DMARC aggregate reports. Add rua=mailto:dmarc@yourdomain.com or use a DMARC reporting service to monitor your authentication results.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Aggregate reports configured (rua=)', detail: 'You will receive DMARC aggregate reports to monitor SPF and DKIM alignment.'});
|
||||
}
|
||||
|
||||
// Alignment
|
||||
if (adkim === 's') {
|
||||
issues.push({type: 'pass', label: 'DKIM alignment: strict', detail: 'Strict DKIM alignment requires the d= domain in the DKIM signature to exactly match the From domain.'});
|
||||
}
|
||||
if (aspf === 's') {
|
||||
issues.push({type: 'pass', label: 'SPF alignment: strict', detail: 'Strict SPF alignment requires the envelope sender domain to exactly match the From domain.'});
|
||||
}
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {policy, subPolicy, pct, hasRua, hasRuf, adkim, aspf, issues, grade};
|
||||
}
|
||||
|
||||
function PolicyBadge({policy}: {policy: string}) {
|
||||
if (policy === 'reject') {
|
||||
return <span className={'rounded-full bg-green-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-green-700 border border-green-200'}>reject</span>;
|
||||
}
|
||||
if (policy === 'quarantine') {
|
||||
return <span className={'rounded-full bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-amber-700 border border-amber-200'}>quarantine</span>;
|
||||
}
|
||||
if (policy === 'none') {
|
||||
return <span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-neutral-600 border border-neutral-300'}>none</span>;
|
||||
}
|
||||
return <span className={'rounded-full bg-red-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-red-700 border border-red-200'}>missing</span>;
|
||||
}
|
||||
|
||||
function GradeBadge({grade, policy}: {grade: 'pass' | 'warning' | 'fail'; policy: string}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required'},
|
||||
};
|
||||
const {cls, label} = map[grade];
|
||||
const sub = policy === 'reject' ? 'Full DMARC protection' : policy === 'quarantine' ? 'Partial protection' : policy === 'none' ? 'Monitoring only — no enforcement' : 'DMARC not enforcing';
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
v: 'DMARC version',
|
||||
p: 'Domain policy for failing messages',
|
||||
sp: 'Subdomain policy override',
|
||||
pct: 'Percentage of messages subject to policy',
|
||||
rua: 'Aggregate report recipients',
|
||||
ruf: 'Forensic report recipients',
|
||||
adkim: 'DKIM alignment mode (r=relaxed, s=strict)',
|
||||
aspf: 'SPF alignment mode (r=relaxed, s=strict)',
|
||||
fo: 'Failure reporting options',
|
||||
rf: 'Forensic report format',
|
||||
ri: 'Reporting interval (seconds)',
|
||||
};
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DMARC?',
|
||||
answer: 'DMARC (Domain-based Message Authentication, Reporting & Conformance) is an email authentication policy that builds on SPF and DKIM. It tells receiving mail servers what to do when an email fails authentication — none (monitor), quarantine (spam folder), or reject (block). DMARC also enables reporting so you can see who is sending email from your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between p=none, quarantine, and reject?',
|
||||
answer: 'p=none means DMARC is in monitoring mode — failures are reported but emails are still delivered. p=quarantine instructs receiving servers to put failing messages in the spam/junk folder. p=reject instructs servers to reject failing messages entirely. The recommended path is to start at p=none, review reports, then progress to quarantine and finally reject.',
|
||||
},
|
||||
{
|
||||
question: 'What are DMARC aggregate reports?',
|
||||
answer: 'Aggregate reports (rua=) are XML reports sent by receiving mail servers summarising how many messages passed or failed SPF and DKIM for your domain. They help you identify all sources sending on your behalf, catch misconfigurations, and detect spoofing attempts. Use a DMARC reporting service to parse and visualise these reports.',
|
||||
},
|
||||
{
|
||||
question: 'Why do I need DMARC if I already have SPF and DKIM?',
|
||||
answer: 'SPF and DKIM independently authenticate different aspects of an email, but neither specifies what to do when authentication fails. DMARC ties them together and enforces a policy. Without DMARC, even a domain with perfect SPF and DKIM offers no protection against spoofing of the visible From header.',
|
||||
},
|
||||
{
|
||||
question: 'What is DMARC alignment?',
|
||||
answer: 'DMARC alignment requires that the domain in a passing SPF or DKIM check matches (or aligns with) the From header domain. Relaxed alignment (r) allows subdomains; strict alignment (s) requires an exact domain match. Alignment is what connects SPF/DKIM to the From header the user sees.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface DmarcLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
record: string | null;
|
||||
tags: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDmarc(domain: string): Promise<DmarcLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_dmarc.${clean}`)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=DMARC1'));
|
||||
if (records.length === 0) return {domain: clean, found: false, record: null, tags: {}};
|
||||
const record: string = records[0]!;
|
||||
const tags: Record<string, string> = {};
|
||||
record.split(';').forEach(part => {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = part.slice(0, eqIdx).trim();
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
if (key) tags[key] = value;
|
||||
}
|
||||
});
|
||||
return {domain: clean, found: true, record, tags};
|
||||
} catch {
|
||||
return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function DmarcCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DmarcLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DmarcAnalysis | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setAnalysis(null);
|
||||
|
||||
try {
|
||||
const data = await lookupDmarc(domain);
|
||||
setResult(data);
|
||||
if (data.found && data.tags) {
|
||||
setAnalysis(analyzeDmarc(data.tags));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DMARC Record Checker | Free DMARC Lookup & Validator | Plunk"
|
||||
description="Free DMARC record checker. Look up and validate your domain's DMARC policy, check reporting configuration, and get step-by-step advice to strengthen email security."
|
||||
canonical="https://www.useplunk.com/tools/dmarc-checker"
|
||||
openGraph={{
|
||||
title: 'DMARC Record Checker | Free DMARC Lookup & Validator | Plunk',
|
||||
description: 'Free DMARC record checker. Validate your DMARC policy and get actionable advice to protect your domain from spoofing.',
|
||||
url: 'https://www.useplunk.com/tools/dmarc-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DMARC+Record+Checker&tag=Tool', alt: 'Plunk DMARC Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-05 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DMARC record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's DMARC record. Understand your current policy, reporting
|
||||
configuration, and get clear advice to progress toward full enforcement.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<ShieldCheck className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DMARC record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<ShieldCheck className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DMARC record…' : 'Check DMARC record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No DMARC record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No DMARC record was found at _dmarc.${result.domain}. Without DMARC, your domain has no enforcement policy and you won't receive authentication reports.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>_dmarc.{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900 break-all'}
|
||||
>
|
||||
{`v=DMARC1; p=none; rua=mailto:dmarc@${result.domain}`}
|
||||
</code>
|
||||
<p className={'mt-2 text-xs text-red-600'}>Start with p=none to monitor, then progress to quarantine and reject.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — _dmarc.{result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} policy={analysis.policy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 items-center gap-3'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'shrink-0 text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
{key === 'p' || key === 'sp' ? (
|
||||
<PolicyBadge policy={value} />
|
||||
) : (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'shrink-0 text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DMARC explained'}
|
||||
title={'From monitoring to full enforcement.'}
|
||||
subtitle={'DMARC is a journey. Start with none, build confidence, then enforce.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
policy: 'p=none',
|
||||
title: 'Monitor',
|
||||
body: 'Start here. DMARC is active but no action is taken on failures. Add rua= to receive aggregate reports and identify all your sending sources.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
policy: 'p=quarantine',
|
||||
title: 'Quarantine',
|
||||
body: 'Once you\'re confident all legitimate senders pass, move to quarantine. Failing messages are sent to spam, reducing spoofing impact.',
|
||||
cls: 'border-amber-300',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
policy: 'p=reject',
|
||||
title: 'Reject',
|
||||
body: 'Full enforcement. Failing messages are rejected by receiving servers. This is the goal — it completely prevents domain spoofing.',
|
||||
cls: 'border-green-400',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.step}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700'}
|
||||
>
|
||||
{item.policy}
|
||||
</code>
|
||||
</div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Email that reaches the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk walks you through SPF, DKIM, and DMARC setup and monitors your sending reputation over time.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dmarc"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DMARC?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dmarc-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, FileText, XCircle} from 'lucide-react';
|
||||
import {Button} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface AuthResult {
|
||||
mechanism: 'spf' | 'dkim' | 'dmarc';
|
||||
result: string;
|
||||
detail: string;
|
||||
pass: boolean | null;
|
||||
}
|
||||
|
||||
interface ReceivedHop {
|
||||
from: string;
|
||||
by: string;
|
||||
timestamp: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface ParsedHeaders {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
subject: string | null;
|
||||
date: string | null;
|
||||
messageId: string | null;
|
||||
replyTo: string | null;
|
||||
returnPath: string | null;
|
||||
xMailer: string | null;
|
||||
dkimSelector: string | null;
|
||||
dkimDomain: string | null;
|
||||
authResults: AuthResult[];
|
||||
hops: ReceivedHop[];
|
||||
}
|
||||
|
||||
function parseRawHeaders(raw: string): Record<string, string[]> {
|
||||
const headers: Record<string, string[]> = {};
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let currentKey = '';
|
||||
let currentValue = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\s/.test(line) && currentKey) {
|
||||
currentValue += ' ' + line.trim();
|
||||
} else {
|
||||
if (currentKey) {
|
||||
const existing = headers[currentKey];
|
||||
if (existing) existing.push(currentValue.trim());
|
||||
else headers[currentKey] = [currentValue.trim()];
|
||||
}
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
currentKey = line.slice(0, colonIdx).trim().toLowerCase();
|
||||
currentValue = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
currentKey = '';
|
||||
currentValue = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentKey) {
|
||||
const existing = headers[currentKey];
|
||||
if (existing) existing.push(currentValue.trim());
|
||||
else headers[currentKey] = [currentValue.trim()];
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseAuthResults(authHeader: string): AuthResult[] {
|
||||
const results: AuthResult[] = [];
|
||||
const lower = authHeader.toLowerCase();
|
||||
|
||||
const mechanisms: Array<'spf' | 'dkim' | 'dmarc'> = ['spf', 'dkim', 'dmarc'];
|
||||
for (const mech of mechanisms) {
|
||||
const mechIdx = lower.indexOf(mech + '=');
|
||||
if (mechIdx === -1) continue;
|
||||
|
||||
const afterMech = authHeader.slice(mechIdx + mech.length + 1);
|
||||
const resultMatch = /^(\w+)/.exec(afterMech);
|
||||
const result = resultMatch?.[1]?.toLowerCase() ?? 'unknown';
|
||||
|
||||
const pass =
|
||||
result === 'pass'
|
||||
? true
|
||||
: result === 'fail' || result === 'hardfail' || result === 'none'
|
||||
? false
|
||||
: null;
|
||||
|
||||
let detail = '';
|
||||
if (mech === 'spf') {
|
||||
const smtpMatch = /smtp\.mailfrom\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (smtpMatch?.[1]) detail = `smtp.mailfrom=${smtpMatch[1]}`;
|
||||
} else if (mech === 'dkim') {
|
||||
const headerMatch = /header\.i\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (headerMatch?.[1]) detail = `header.i=${headerMatch[1]}`;
|
||||
} else if (mech === 'dmarc') {
|
||||
const headerMatch = /header\.from\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (headerMatch?.[1]) detail = `header.from=${headerMatch[1]}`;
|
||||
}
|
||||
|
||||
results.push({mechanism: mech, result, detail, pass});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseReceivedHop(receivedValue: string): ReceivedHop {
|
||||
const fromMatch = /from\s+([^\s]+)/i.exec(receivedValue);
|
||||
const byMatch = /by\s+([^\s]+)/i.exec(receivedValue);
|
||||
|
||||
const datePattern = /;\s*(.+)$/;
|
||||
const dateMatch = datePattern.exec(receivedValue);
|
||||
let timestamp: string | null = null;
|
||||
if (dateMatch?.[1]) {
|
||||
const parsed = new Date(dateMatch[1].trim());
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
timestamp = parsed.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: fromMatch?.[1] ?? '(unknown)',
|
||||
by: byMatch?.[1] ?? '(unknown)',
|
||||
timestamp,
|
||||
raw: receivedValue,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDkimSignature(dkimValue: string): {selector: string | null; domain: string | null} {
|
||||
const sMatch = /\bs\s*=\s*([^;\s]+)/i.exec(dkimValue);
|
||||
const dMatch = /\bd\s*=\s*([^;\s]+)/i.exec(dkimValue);
|
||||
return {selector: sMatch?.[1] ?? null, domain: dMatch?.[1] ?? null};
|
||||
}
|
||||
|
||||
function analyzeHeaders(raw: string): ParsedHeaders | null {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const headers = parseRawHeaders(raw);
|
||||
const first = (key: string) => headers[key]?.[0] ?? null;
|
||||
|
||||
const authHeader = first('authentication-results');
|
||||
const authResults = authHeader ? parseAuthResults(authHeader) : [];
|
||||
|
||||
const receivedHeaders = headers['received'] ?? [];
|
||||
const hops = receivedHeaders.map(parseReceivedHop).reverse();
|
||||
|
||||
const dkimSig = first('dkim-signature');
|
||||
const {selector, domain} = dkimSig ? parseDkimSignature(dkimSig) : {selector: null, domain: null};
|
||||
|
||||
return {
|
||||
from: first('from'),
|
||||
to: first('to'),
|
||||
subject: first('subject'),
|
||||
date: first('date'),
|
||||
messageId: first('message-id'),
|
||||
replyTo: first('reply-to'),
|
||||
returnPath: first('return-path'),
|
||||
xMailer: first('x-mailer') ?? first('x-mailing-list') ?? null,
|
||||
dkimSelector: selector,
|
||||
dkimDomain: domain,
|
||||
authResults,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
function AuthBadge({result}: {result: string}) {
|
||||
const pass = result === 'pass';
|
||||
const fail = result === 'fail' || result === 'hardfail';
|
||||
const cls = pass
|
||||
? 'bg-green-100 text-green-700'
|
||||
: fail
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-neutral-100 text-neutral-600';
|
||||
return (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={`rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Where do I find the raw email headers?',
|
||||
answer: 'In Gmail: open the email, click the three-dot menu (⋮), choose "Show original". In Outlook: open the email, click File → Properties, and copy the "Internet headers" box. In Apple Mail: with the email open, go to View → Message → All Headers. The raw headers appear at the top of the message source.',
|
||||
},
|
||||
{
|
||||
question: 'What do the Authentication-Results headers mean?',
|
||||
answer: 'Authentication-Results is added by the receiving mail server and summarises the SPF, DKIM, and DMARC check results. "pass" means the check succeeded. "fail" or "hardfail" means it failed. "none" means no record was found. If DMARC passes but SPF or DKIM fails, check that your sending domain aligns with the From header.',
|
||||
},
|
||||
{
|
||||
question: 'How do I read the Received chain?',
|
||||
answer: 'Received headers are added by each mail server the message passed through, with the most recent at the top. Reading them bottom-to-top gives you the routing path from sender to inbox. Large time gaps between hops indicate server delays or queuing. The very first Received header shows where the message originated.',
|
||||
},
|
||||
{
|
||||
question: 'Why does email land in spam even though SPF and DKIM pass?',
|
||||
answer: 'Authentication passing is necessary but not sufficient. Spam filters also consider sender reputation, IP blacklist status, content quality, engagement history, and DMARC alignment. A brand-new IP, a domain with no sending history, or content with many spam trigger words can cause filtering even with perfect authentication.',
|
||||
},
|
||||
{
|
||||
question: 'What is the DKIM-Signature header?',
|
||||
answer: 'DKIM-Signature contains a cryptographic signature applied by the sending server. The "d=" tag identifies the signing domain and "s=" is the selector used to locate the public key in DNS. Receiving servers verify the signature against the public key at <selector>._domainkey.<domain>. A mismatch or missing key causes a DKIM fail.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailHeadersPage() {
|
||||
const [raw, setRaw] = useState('');
|
||||
const [result, setResult] = useState<ParsedHeaders | null>(null);
|
||||
const [analysed, setAnalysed] = useState(false);
|
||||
|
||||
const handleAnalyze = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = analyzeHeaders(raw);
|
||||
setResult(parsed);
|
||||
setAnalysed(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setRaw('');
|
||||
setResult(null);
|
||||
setAnalysed(false);
|
||||
};
|
||||
|
||||
const metaFields: Array<{label: string; value: string | null}> = result
|
||||
? [
|
||||
{label: 'From', value: result.from},
|
||||
{label: 'To', value: result.to},
|
||||
{label: 'Subject', value: result.subject},
|
||||
{label: 'Date', value: result.date},
|
||||
{label: 'Message-ID', value: result.messageId},
|
||||
{label: 'Reply-To', value: result.replyTo},
|
||||
{label: 'Return-Path', value: result.returnPath},
|
||||
{label: 'X-Mailer', value: result.xMailer},
|
||||
].filter(f => f.value !== null)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Email Headers Analyzer | Parse & Debug Email Headers | Plunk"
|
||||
description="Free email headers analyzer. Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops, and authentication chain. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/email-headers"
|
||||
openGraph={{
|
||||
title: 'Email Headers Analyzer | Parse & Debug Email Headers | Plunk',
|
||||
description: 'Paste raw email headers and instantly see SPF/DKIM/DMARC results, routing hops, and authentication chain. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/email-headers',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Headers+Analyzer&tag=Tool', alt: 'Plunk Email Headers Analyzer', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-09 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
Email headers
|
||||
<br />
|
||||
analyzer
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops with timestamps, and the full authentication chain.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<FileText className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Raw header input
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAnalyze} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="rawHeaders" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Raw email headers <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rawHeaders"
|
||||
value={raw}
|
||||
onChange={e => setRaw(e.target.value)}
|
||||
placeholder={'Paste raw headers here.\n\nIn Gmail: ⋮ menu → Show original\nIn Outlook: File → Properties → Internet headers\nIn Apple Mail: View → Message → All Headers'}
|
||||
required
|
||||
rows={10}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'w-full resize-y rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-xs text-neutral-700 placeholder:text-neutral-400 focus:border-neutral-400 focus:bg-white focus:outline-none'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Paste the full raw headers — no email body required. Everything is processed locally in your browser.</p>
|
||||
</div>
|
||||
<div className={'flex gap-3'}>
|
||||
<Button type="submit" className={'flex-1 gap-2'}>
|
||||
<FileText className={'h-4 w-4'} />
|
||||
Analyze headers
|
||||
</Button>
|
||||
{analysed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={'rounded-full border border-neutral-200 px-5 py-2.5 text-sm font-medium text-neutral-600 transition hover:border-neutral-400 hover:text-neutral-900'}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{analysed && result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{/* Authentication results */}
|
||||
{result.authResults.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Authentication results
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.authResults.map((auth, i) => (
|
||||
<li key={i} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
{auth.pass === true ? (
|
||||
<CheckCircle className={'mt-0.5 h-4 w-4 shrink-0 text-green-600'} />
|
||||
) : auth.pass === false ? (
|
||||
<XCircle className={'mt-0.5 h-4 w-4 shrink-0 text-red-500'} />
|
||||
) : (
|
||||
<AlertTriangle className={'mt-0.5 h-4 w-4 shrink-0 text-amber-500'} />
|
||||
)}
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<div className={'flex flex-wrap items-center gap-2'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold uppercase tracking-wide text-neutral-700'}>
|
||||
{auth.mechanism}
|
||||
</span>
|
||||
<AuthBadge result={auth.result} />
|
||||
{auth.detail && (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs text-neutral-400'}>
|
||||
{auth.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.authResults.length === 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-100 bg-neutral-50 p-6'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-800'}>No Authentication-Results header found</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>This header is added by the receiving mail server. If it's missing, paste the full original headers, not just the visible ones.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DKIM signature info */}
|
||||
{(result.dkimDomain ?? result.dkimSelector) && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-4 text-lg font-bold text-neutral-900'}>
|
||||
DKIM signature
|
||||
</h3>
|
||||
<div className={'flex flex-wrap gap-4'}>
|
||||
{result.dkimDomain && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Signing domain</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimDomain}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.dkimSelector && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Selector</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimSelector}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.dkimDomain && result.dkimSelector && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Public key DNS name</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>
|
||||
{result.dkimSelector}._domainkey.{result.dkimDomain}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routing hops */}
|
||||
{result.hops.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Routing hops <span className={'text-base font-normal text-neutral-400'}>({result.hops.length} server{result.hops.length !== 1 ? 's' : ''})</span>
|
||||
</h3>
|
||||
<ol className={'space-y-3'}>
|
||||
{result.hops.map((hop, i) => (
|
||||
<li key={i} className={'flex gap-4'}>
|
||||
<div className={'flex flex-col items-center'}>
|
||||
<div className={'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-white'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[10px] font-bold text-neutral-500'}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
{i < result.hops.length - 1 && <div className={'mt-1 h-full w-px bg-neutral-200'} />}
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1 pb-3'}>
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex flex-wrap items-start justify-between gap-2'}>
|
||||
<div className={'min-w-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>From</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.from}</p>
|
||||
</div>
|
||||
<div className={'min-w-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>By</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.by}</p>
|
||||
</div>
|
||||
{hop.timestamp && (
|
||||
<div className={'shrink-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Time</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 text-xs text-neutral-600'}>{hop.timestamp}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message metadata */}
|
||||
{metaFields.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Message metadata
|
||||
</h3>
|
||||
<dl className={'space-y-3'}>
|
||||
{metaFields.map(field => (
|
||||
<div key={field.label} className={'flex flex-col gap-1 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3 sm:flex-row sm:gap-4'}>
|
||||
<dt style={{fontFamily: 'var(--font-mono)'}} className={'w-28 shrink-0 text-xs font-bold text-neutral-500'}>
|
||||
{field.label}
|
||||
</dt>
|
||||
<dd style={{fontFamily: 'var(--font-mono)'}} className={'min-w-0 break-all text-xs text-neutral-800'}>
|
||||
{field.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{analysed && !result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 rounded-[20px] border border-red-100 bg-red-50 p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>Could not parse headers</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>Make sure you pasted raw email headers in standard format. Each header should be on its own line followed by a colon and value.</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'Email headers explained'}
|
||||
title={'What headers tell you.'}
|
||||
subtitle={'Every email carries a forensic trail. Here\'s what to look for when debugging deliverability.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
tag: 'Authentication',
|
||||
title: 'SPF, DKIM & DMARC',
|
||||
body: 'The Authentication-Results header is your first stop. It shows whether the sending server was authorised (SPF), the signature was valid (DKIM), and whether both align with the From domain (DMARC).',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
tag: 'Routing',
|
||||
title: 'Received chain',
|
||||
body: 'Received headers trace the path of your email from origin to inbox. Read them bottom-to-top. Large time gaps reveal where delays happened — useful for diagnosing why an email arrived late.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
tag: 'Identity',
|
||||
title: 'Return-Path & Reply-To',
|
||||
body: 'Return-Path is where bounces go — it must pass SPF. Reply-To overrides where replies are sent. A mismatch between From, Return-Path, and Reply-To can trigger spam filters or indicate phishing.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.tag}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>
|
||||
/ {item.tag}
|
||||
</span>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Never debug deliverability again.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk sets up authentication correctly from day one and gives you the analytics to catch deliverability issues before your users do.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/tools/dkim-checker"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
Check DKIM record
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-email-headers" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, ArrowUpRight, Code2, Search, ShieldAlert} from 'lucide-react';
|
||||
import {ArrowRight, ArrowUpRight, Code2, FileText, Key, Mail, Search, Shield, ShieldAlert, ShieldCheck} from 'lucide-react';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
@@ -50,19 +50,54 @@ const tools = [
|
||||
icon: ShieldAlert,
|
||||
number: '03',
|
||||
},
|
||||
{
|
||||
name: 'SPF Checker',
|
||||
slug: 'spf-checker',
|
||||
description: 'Look up and validate your domain\'s SPF record. Catch misconfigurations before they hurt deliverability.',
|
||||
icon: Shield,
|
||||
number: '04',
|
||||
},
|
||||
{
|
||||
name: 'DMARC Checker',
|
||||
slug: 'dmarc-checker',
|
||||
description: 'Check your DMARC policy, reporting configuration, and get step-by-step advice toward full enforcement.',
|
||||
icon: ShieldCheck,
|
||||
number: '05',
|
||||
},
|
||||
{
|
||||
name: 'DKIM Checker',
|
||||
slug: 'dkim-checker',
|
||||
description: 'Look up your DKIM public key by selector. Verify it\'s active and correctly configured.',
|
||||
icon: Key,
|
||||
number: '06',
|
||||
},
|
||||
{
|
||||
name: 'MX Record Checker',
|
||||
slug: 'mx-checker',
|
||||
description: 'Look up mail exchange records for any domain. Check server priority and diagnose delivery problems.',
|
||||
icon: Mail,
|
||||
number: '07',
|
||||
},
|
||||
{
|
||||
name: 'Email Headers Analyzer',
|
||||
slug: 'email-headers',
|
||||
description: 'Paste raw email headers and get SPF/DKIM/DMARC results, routing hops, and the full authentication chain.',
|
||||
icon: FileText,
|
||||
number: '08',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolsIndex() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Email Tools | Spam Checker, Email Validator & More | Plunk"
|
||||
description="Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required."
|
||||
title="Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk"
|
||||
description="Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Spam Checker, Email Validator & More | Plunk',
|
||||
title: 'Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk',
|
||||
description:
|
||||
'Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
|
||||
'Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required.',
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
|
||||
}}
|
||||
@@ -111,7 +146,7 @@ export default function ToolsIndex() {
|
||||
developer tools
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
|
||||
Check MX records, email headers, SPF, DMARC, and DKIM. Verify addresses, convert markdown to email-safe HTML, test for spam, and more. No sign-up required.
|
||||
</p>
|
||||
|
||||
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Mail, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface MxRecord {
|
||||
priority: number;
|
||||
exchange: string;
|
||||
}
|
||||
|
||||
interface MxLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
records: MxRecord[];
|
||||
issues: MxIssue[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MxIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
function parseMxData(data: string): MxRecord {
|
||||
const parts = data.trim().split(/\s+/);
|
||||
const priority = parseInt(parts[0] ?? '0', 10);
|
||||
const exchange = (parts[1] ?? '').replace(/\.$/, '');
|
||||
return {priority, exchange};
|
||||
}
|
||||
|
||||
function analyzeMx(records: MxRecord[]): MxIssue[] {
|
||||
const issues: MxIssue[] = [];
|
||||
|
||||
if (records.length === 0) {
|
||||
issues.push({type: 'error', label: 'No MX records found', detail: 'Without MX records, mail servers cannot deliver email to this domain. Add at least one MX record pointing to your mail server.'});
|
||||
return issues;
|
||||
}
|
||||
|
||||
issues.push({type: 'pass', label: `${records.length} MX record${records.length > 1 ? 's' : ''} found`, detail: 'Mail servers can look up where to deliver email for this domain.'});
|
||||
|
||||
if (records.length === 1) {
|
||||
issues.push({type: 'warning', label: 'Single MX record — no redundancy', detail: 'If this mail server is unavailable, email delivery will fail. Consider adding a secondary MX record with a higher priority number as a fallback.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Multiple MX records — redundancy configured', detail: 'If the primary server is unreachable, mail will fall back to lower-priority servers.'});
|
||||
}
|
||||
|
||||
const priorities = records.map(r => r.priority);
|
||||
const uniquePriorities = new Set(priorities);
|
||||
if (uniquePriorities.size < priorities.length) {
|
||||
issues.push({type: 'warning', label: 'Duplicate priority values', detail: 'Two or more MX records share the same priority. When priorities are equal, senders choose randomly between them. This may be intentional for load balancing, but verify it matches your setup.'});
|
||||
}
|
||||
|
||||
const hasMxPointingToIp = records.some(r => /^\d+\.\d+\.\d+\.\d+$/.test(r.exchange));
|
||||
if (hasMxPointingToIp) {
|
||||
issues.push({type: 'error', label: 'MX record points to an IP address', detail: 'MX records must point to a hostname, not an IP address. This is an RFC 5321 violation and many mail servers will reject mail. Change the MX value to a fully qualified domain name.'});
|
||||
}
|
||||
|
||||
const hasTrailingDot = records.some(r => r.exchange.endsWith('.'));
|
||||
if (!hasTrailingDot && records.some(r => r.exchange.includes('.'))) {
|
||||
issues.push({type: 'pass', label: 'MX hostnames look well-formed', detail: 'All exchange hostnames appear to be valid fully qualified domain names.'});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function lookupMx(domain: string): Promise<MxLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(clean)}&type=MX`,
|
||||
{headers: {Accept: 'application/dns-json'}},
|
||||
);
|
||||
if (!res.ok) return {domain: clean, found: false, records: [], issues: [], error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Status: number; Answer?: DnsAnswer[]};
|
||||
if (data.Status !== 0 || !Array.isArray(data.Answer) || data.Answer.length === 0) {
|
||||
const issues = analyzeMx([]);
|
||||
return {domain: clean, found: false, records: [], issues};
|
||||
}
|
||||
const records: MxRecord[] = data.Answer.map((a: DnsAnswer) => parseMxData(a.data)).sort((a, b) => a.priority - b.priority);
|
||||
const issues = analyzeMx(records);
|
||||
return {domain: clean, found: true, records, issues};
|
||||
} catch {
|
||||
return {domain: clean, found: false, records: [], issues: [], error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(priority: number): string {
|
||||
if (priority === 0) return 'Primary';
|
||||
if (priority <= 10) return 'Primary';
|
||||
if (priority <= 20) return 'Secondary';
|
||||
return 'Fallback';
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is an MX record?',
|
||||
answer: 'An MX (Mail Exchanger) record is a DNS record that specifies which mail servers are responsible for receiving email for a domain. When someone sends an email to you@yourdomain.com, the sending mail server looks up the MX records for yourdomain.com to find where to deliver the message.',
|
||||
},
|
||||
{
|
||||
question: 'What does the MX priority number mean?',
|
||||
answer: 'The priority number (also called preference) tells senders which mail server to try first. Lower numbers = higher priority. A server with priority 10 is tried before one with priority 20. If the primary server is unavailable, senders retry with the next highest priority server. Equal priority values means senders choose randomly — this is sometimes used for load balancing.',
|
||||
},
|
||||
{
|
||||
question: 'Do I need more than one MX record?',
|
||||
answer: 'It\'s strongly recommended. A single MX record means email will bounce or queue during any outage. Adding a secondary MX (typically your mail provider\'s backup server, or a service like Google MX backup) ensures email is accepted even if your primary server is temporarily down.',
|
||||
},
|
||||
{
|
||||
question: 'Why would email delivery fail even if MX records are correct?',
|
||||
answer: 'Several reasons: the mail server the MX points to may be down or unreachable on port 25; your IP or domain may be on a blacklist; SPF, DKIM, or DMARC might be misconfigured causing rejection; or the receiving server may be filtering based on content. MX records are just the first step — authentication records and reputation matter too.',
|
||||
},
|
||||
{
|
||||
question: 'Can I change my MX records without losing email?',
|
||||
answer: 'Yes, with care. Lower your TTL to 300 seconds a day before making changes so the change propagates quickly. Add the new MX records before removing the old ones. Wait for propagation (typically 5–30 minutes with a low TTL), confirm the new server is receiving, then remove the old records and restore your TTL.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MxCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<MxLookupResult | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await lookupMx(domain);
|
||||
setResult(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="MX Record Checker | Free MX Lookup Tool | Plunk"
|
||||
description="Free MX record checker. Look up the mail exchange records for any domain, see server priority, and diagnose email delivery problems. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/mx-checker"
|
||||
openGraph={{
|
||||
title: 'MX Record Checker | Free MX Lookup Tool | Plunk',
|
||||
description: 'Look up MX records for any domain. Check mail server priority, redundancy, and diagnose delivery problems. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/mx-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=MX+Record+Checker&tag=Tool', alt: 'Plunk MX Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-08 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
MX record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up the mail exchange records for any domain. See mail server priority, check for redundancy, and diagnose email delivery problems.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Mail className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
MX record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="mxDomain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="mxDomain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Mail className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up MX records…' : 'Check MX records'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{result.error ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>DNS lookup failed</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>Could not query MX records for {result.domain}. Check the domain name and try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!result.found || result.records.length === 0 ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No MX records found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.domain} has no MX records. Email cannot be delivered to this domain. Add at least one MX record in your DNS provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
MX records for {result.domain}
|
||||
</h3>
|
||||
<div className={'space-y-3'}>
|
||||
{result.records.map((rec, i) => (
|
||||
<div key={i} className={'flex items-center gap-4 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-white'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold tabular-nums text-neutral-700'}>
|
||||
{rec.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'block truncate text-sm font-medium text-neutral-900'}>
|
||||
{rec.exchange || '(empty)'}
|
||||
</span>
|
||||
<span className={'text-xs text-neutral-500'}>Priority {rec.priority} · {priorityLabel(rec.priority)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.issues.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Analysis
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'MX records explained'}
|
||||
title={'How email finds its destination.'}
|
||||
subtitle={'MX records are the address book of email routing — without them, no one can send you mail.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
title: 'Sender looks up MX',
|
||||
body: 'When someone sends to you@domain.com, their mail server queries DNS for the MX records of domain.com. The priority numbers tell it which server to try first.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Connection to port 25',
|
||||
body: 'The sending server opens an SMTP connection to port 25 on your mail server\'s hostname. If the connection fails, it tries the next MX in priority order.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Authentication checks',
|
||||
body: 'Your mail server runs SPF, DKIM, and DMARC checks on the incoming message. Failures may cause the message to be filtered or rejected depending on your policy.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.step}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Email that reaches the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk handles SPF, DKIM, and DMARC setup and keeps your sending reputation clean so email lands in the inbox.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/tools/spf-checker"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
Check SPF record
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-mx-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Shield, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface SpfMechanism {
|
||||
qualifier: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SpfIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface ParsedSpf {
|
||||
mechanisms: SpfMechanism[];
|
||||
allMechanism: string | null;
|
||||
issues: SpfIssue[];
|
||||
lookupCount: number;
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
const DNS_LOOKUP_MECHS = new Set(['include', 'a', 'mx', 'ptr', 'exists', 'redirect']);
|
||||
|
||||
function parseSpfRecord(record: string): ParsedSpf {
|
||||
const parts = record.split(/\s+/);
|
||||
const mechanisms: SpfMechanism[] = [];
|
||||
let allMechanism: string | null = null;
|
||||
const issues: SpfIssue[] = [];
|
||||
let lookupCount = 0;
|
||||
|
||||
for (const part of parts.slice(1)) {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === '+all' || lower === '-all' || lower === '~all' || lower === '?all' || lower === 'all') {
|
||||
allMechanism = lower === 'all' ? '+all' : lower;
|
||||
continue;
|
||||
}
|
||||
|
||||
let qualifier = '+';
|
||||
let mech = part;
|
||||
const firstChar = part[0] ?? '';
|
||||
if (['+', '-', '~', '?'].includes(firstChar)) {
|
||||
qualifier = firstChar;
|
||||
mech = part.slice(1);
|
||||
}
|
||||
|
||||
const colonIdx = mech.indexOf(':');
|
||||
const slashIdx = mech.indexOf('/');
|
||||
const endIdx = colonIdx > -1 ? colonIdx : slashIdx > -1 ? slashIdx : mech.length;
|
||||
const type = mech.slice(0, endIdx).toLowerCase();
|
||||
const value = colonIdx > -1 ? mech.slice(colonIdx + 1) : '';
|
||||
|
||||
if (DNS_LOOKUP_MECHS.has(type)) lookupCount++;
|
||||
|
||||
mechanisms.push({qualifier, type, value});
|
||||
}
|
||||
|
||||
// Analyze
|
||||
if (!allMechanism) {
|
||||
issues.push({type: 'warning', label: 'No "all" mechanism', detail: 'SPF records should end with -all, ~all, or ?all to define default behaviour for unlisted senders.'});
|
||||
} else if (allMechanism === '+all') {
|
||||
issues.push({type: 'error', label: '+all allows any server to send', detail: '+all means any mail server in the world can send email claiming to be from your domain, completely defeating SPF protection.'});
|
||||
} else if (allMechanism === '?all') {
|
||||
issues.push({type: 'warning', label: '?all is too permissive', detail: '?all (neutral) gives no protection. Consider upgrading to ~all (softfail) or -all (hard fail).'});
|
||||
} else if (allMechanism === '~all') {
|
||||
issues.push({type: 'warning', label: '~all softfail is acceptable but not optimal', detail: 'Softfail marks unauthorised senders as suspicious but still delivers them. Prefer -all for maximum protection once your legitimate senders are configured.'});
|
||||
} else if (allMechanism === '-all') {
|
||||
issues.push({type: 'pass', label: '-all hard fail is the strongest policy', detail: 'Mail servers are instructed to reject email from any server not listed in your SPF record.'});
|
||||
}
|
||||
|
||||
if (lookupCount > 8) {
|
||||
issues.push({type: 'error', label: `DNS lookup limit exceeded (${lookupCount}/10)`, detail: `SPF is limited to 10 DNS lookups. Exceeding this causes a PermError, making SPF fail permanently. Consolidate includes or use IP ranges.`});
|
||||
} else if (lookupCount > 6) {
|
||||
issues.push({type: 'warning', label: `Approaching DNS lookup limit (${lookupCount}/10)`, detail: 'Adding more senders could push you over the 10 lookup limit. Monitor and consolidate where possible.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: `DNS lookups within limit (${lookupCount}/10)`, detail: 'Your SPF record uses an acceptable number of DNS lookups.'});
|
||||
}
|
||||
|
||||
const ptrMechs = mechanisms.filter(m => m.type === 'ptr');
|
||||
if (ptrMechs.length > 0) {
|
||||
issues.push({type: 'warning', label: 'ptr mechanism is deprecated', detail: 'The ptr mechanism is slow and unreliable. RFC 7208 recommends avoiding it. Use ip4/ip6 or include instead.'});
|
||||
}
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {mechanisms, allMechanism, issues, lookupCount, grade};
|
||||
}
|
||||
|
||||
function qualifierLabel(q: string) {
|
||||
if (q === '+') return {label: 'PASS', cls: 'bg-green-50 text-green-700 border-green-200'};
|
||||
if (q === '-') return {label: 'FAIL', cls: 'bg-red-50 text-red-700 border-red-200'};
|
||||
if (q === '~') return {label: 'SOFTFAIL', cls: 'bg-amber-50 text-amber-700 border-amber-200'};
|
||||
return {label: 'NEUTRAL', cls: 'bg-neutral-100 text-neutral-600 border-neutral-300'};
|
||||
}
|
||||
|
||||
function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'SPF is properly configured'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'SPF has configuration issues'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required', sub: 'SPF has critical errors'},
|
||||
};
|
||||
const {cls, label, sub} = map[grade];
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is an SPF record?',
|
||||
answer: 'An SPF (Sender Policy Framework) record is a DNS TXT record that lists the mail servers authorised to send email on behalf of your domain. Receiving mail servers check this record to verify that incoming email claiming to be from your domain was sent by an authorised server. Without SPF, anyone can spoof your domain in the From address.',
|
||||
},
|
||||
{
|
||||
question: 'What does -all vs ~all mean?',
|
||||
answer: '-all (hard fail) instructs receiving servers to reject any email not matching your SPF record. ~all (softfail) marks non-matching emails as suspicious but still delivers them. For production domains, -all is recommended once all your legitimate sending sources are added.',
|
||||
},
|
||||
{
|
||||
question: 'Why is there a 10 DNS lookup limit?',
|
||||
answer: 'RFC 7208 limits SPF to 10 DNS lookups to prevent denial-of-service attacks and excessive DNS load. Each include, a, mx, ptr, and exists mechanism counts as one lookup. Exceeding 10 lookups causes a PermError, which effectively makes SPF fail for your domain.',
|
||||
},
|
||||
{
|
||||
question: 'Can I have multiple SPF records?',
|
||||
answer: 'No. Having more than one SPF (v=spf1) TXT record on your domain causes a PermError and breaks SPF authentication. If you need to authorise multiple senders, combine everything into a single SPF record using multiple mechanisms.',
|
||||
},
|
||||
{
|
||||
question: 'Does SPF alone protect against spoofing?',
|
||||
answer: 'SPF alone is not enough. SPF only validates the envelope sender (the "Return-Path" address), not the visible "From" header. DMARC is required to connect SPF (and DKIM) validation to the From header and actually prevent spoofing of your visible sender address.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface SpfLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
multiple: boolean;
|
||||
records: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupSpf(domain: string): Promise<SpfLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(clean)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=spf1'));
|
||||
return {domain: clean, found: records.length > 0, multiple: records.length > 1, records};
|
||||
} catch {
|
||||
return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function SpfCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<SpfLookupResult | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSpf | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
|
||||
try {
|
||||
const data = await lookupSpf(domain);
|
||||
setResult(data);
|
||||
if (data.found && data.records[0]) {
|
||||
setParsed(parseSpfRecord(data.records[0]));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="SPF Record Checker | Free SPF Lookup & Validator | Plunk"
|
||||
description="Free SPF record checker. Look up and validate your domain's SPF record, check for misconfigurations, and get actionable advice to improve email deliverability."
|
||||
canonical="https://www.useplunk.com/tools/spf-checker"
|
||||
openGraph={{
|
||||
title: 'SPF Record Checker | Free SPF Lookup & Validator | Plunk',
|
||||
description: 'Free SPF record checker. Look up and validate your domain SPF record and get actionable deliverability advice.',
|
||||
url: 'https://www.useplunk.com/tools/spf-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+SPF+Record+Checker&tag=Tool', alt: 'Plunk SPF Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-04 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
SPF record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's SPF record. Get a full breakdown of your sending policy and
|
||||
catch misconfigurations before they hurt deliverability.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Shield className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
SPF record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Shield className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up SPF record…' : 'Check SPF record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No SPF record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No SPF record was found for ${result.domain}. Without SPF, anyone can send email impersonating your domain, and legitimate emails are more likely to land in spam.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900'}
|
||||
>
|
||||
v=spf1 include:your-email-provider.com -all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.multiple && (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>Multiple SPF records detected</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
Having more than one SPF record causes a PermError, breaking SPF for your domain. Merge all mechanisms into a single v=spf1 record.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — {result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.records[0]}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{parsed && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={parsed.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mechanisms */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Sending mechanisms
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{parsed.mechanisms.map((m, i) => {
|
||||
const {label, cls} = qualifierLabel(m.qualifier);
|
||||
return (
|
||||
<div key={i} className={'flex items-center gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-medium text-neutral-700'}>
|
||||
{m.type}
|
||||
</span>
|
||||
{m.value && (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{m.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{parsed.allMechanism && (
|
||||
<div className={'flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3'}>
|
||||
<span
|
||||
className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${qualifierLabel(parsed.allMechanism[0] ?? '+').cls}`}
|
||||
>
|
||||
{qualifierLabel(parsed.allMechanism[0] ?? '+').label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-semibold text-neutral-700'}>
|
||||
all
|
||||
</span>
|
||||
<span className={'text-xs text-neutral-500'}>— default for all other senders</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{parsed.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'SPF explained'}
|
||||
title={'How SPF protects your domain.'}
|
||||
subtitle={'SPF is the first line of defence against email spoofing and phishing.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Authorise senders',
|
||||
body: 'SPF lets you publish a list of mail servers allowed to send on your behalf. Any server not on the list fails SPF validation.',
|
||||
},
|
||||
{
|
||||
title: 'Prevent spoofing',
|
||||
body: 'Without SPF, anyone can claim to send email from your domain. SPF makes it possible for receiving servers to detect and reject spoofed messages.',
|
||||
},
|
||||
{
|
||||
title: '10 lookup limit',
|
||||
body: 'SPF allows at most 10 DNS lookups per evaluation. Exceeding this causes a PermError, making SPF permanently fail. Monitor your lookup count carefully.',
|
||||
},
|
||||
{
|
||||
title: 'SPF is not enough alone',
|
||||
body: 'SPF validates the envelope sender, not the visible From header. You need DMARC to tie SPF (and DKIM) results to the From header and actually block spoofed email.',
|
||||
},
|
||||
{
|
||||
title: 'One record only',
|
||||
body: 'A domain must have exactly one SPF TXT record. Multiple v=spf1 records cause a PermError. Merge all sending sources into a single record.',
|
||||
},
|
||||
{
|
||||
title: 'Hard fail vs softfail',
|
||||
body: '-all (hard fail) instructs servers to reject non-matching mail. ~all (softfail) marks it as suspicious. Use -all in production once all senders are listed.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
SPF, DKIM, and DMARC — handled.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk guides you through domain authentication setup and monitors your sending reputation.
|
||||
Start free, no credit card required.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-spf"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is SPF?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-spf-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,6 +170,8 @@ export default function ActiveCampaignComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||
|
||||
<SwitchOffer competitorName="ActiveCampaign" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} 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 {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: '62,000 emails/month (from EC2)'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Pay-as-you-go (delivery only)'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: 'N/A (AWS-managed)'},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: true},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: false},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
|
||||
{feature: 'Contact Management', plunk: true, competitor: false},
|
||||
{feature: 'Dashboard & Analytics', plunk: true, competitor: false},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I use raw Amazon SES instead of Plunk?',
|
||||
answer:
|
||||
'Use raw SES if you have an existing email platform and only need a delivery layer, or if your engineering team wants full control over every part of the stack and has the capacity to build contact management, unsubscribe handling, bounce processing, and analytics themselves. Plunk is built on SES — when you self-host Plunk, you get the full platform at near-SES prices.',
|
||||
},
|
||||
{
|
||||
question: 'Is Plunk more expensive than Amazon SES?',
|
||||
answer:
|
||||
"SES charges $0.10 per 1,000 emails ($0.0001/email) for delivery alone. Plunk's managed service is $0.001/email — 10x more, but that includes contact management, campaigns, automation, segmentation, bounce/complaint handling, an admin dashboard, and unsubscribe management. If you self-host Plunk on your own infrastructure, you pay SES rates directly plus your hosting costs.",
|
||||
},
|
||||
{
|
||||
question: 'Does Plunk use Amazon SES under the hood?',
|
||||
answer:
|
||||
'Yes. Plunk uses AWS SES for email delivery. That means you get the same deliverability infrastructure as SES, plus the full platform layer on top. When you self-host Plunk, your SES account handles delivery directly — giving you SES pricing with Plunk functionality.',
|
||||
},
|
||||
{
|
||||
question: 'How hard is it to migrate from SES to Plunk?',
|
||||
answer:
|
||||
"If you're currently sending through SES directly, migrating to Plunk means switching your sending code to use Plunk's API instead of the SES SDK. Your existing SES domain verification and DKIM configuration can carry over. The benefit: you immediately gain contact management, bounce handling, unsubscribe lists, campaign tools, and a dashboard — without building any of that yourself.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AmazonSesComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Amazon SES: Full Email Platform vs Raw Delivery | Plunk"
|
||||
description="Amazon SES only handles delivery — no contacts, no campaigns, no dashboard. Plunk is the full email platform built on SES infrastructure. Open-source, self-hostable, with marketing and automation included."
|
||||
canonical="https://www.useplunk.com/vs/amazon-ses"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Amazon SES: Full Email Platform vs Raw Delivery | Plunk',
|
||||
description:
|
||||
'SES is raw email delivery. Plunk is the full platform built on SES — contact management, campaigns, automation, and analytics. Open-source and self-hostable.',
|
||||
url: 'https://www.useplunk.com/vs/amazon-ses',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Plunk+vs+Amazon+SES&tag=Comparison', alt: 'Plunk vs Amazon SES', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Amazon SES
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
The full platform,
|
||||
<br />
|
||||
not just delivery.
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Amazon SES is raw email infrastructure — no contacts, no campaigns, no dashboard. Plunk is the complete email platform built on top of SES. Open-source, self-hostable, and available managed at $0.001 per email.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
The Real Cost Comparison
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>SES is cheap per email — but you still have to build everything else yourself</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Complete platform, managed</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>Everything included. Or self-host and pay SES rates directly.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001 / email</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Contact management, campaigns, automation</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Bounce & complaint handling built in</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Self-host for near-SES pricing</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Amazon SES</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Delivery infrastructure only</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Cheap delivery — but everything else is your problem to build.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>$0.0001 / email</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No contacts, no campaigns, no dashboard</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />You must handle bounces and complaints yourself</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Significant engineering cost to build the rest</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Provides</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything SES doesn't — ready to use, not to build</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm: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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Contact Management</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Store and manage your subscribers. Track events, manage unsubscribes, and build dynamic segments. SES has no concept of contacts.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send one-time broadcasts, schedule campaigns, track opens and clicks. SES delivers bytes — Plunk runs your entire email program.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Multi-step sequences triggered by events. Onboarding flows, drip campaigns, re-engagement. None of this exists in SES — you'd build it yourself.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute, and self-host. You get all the benefits of SES deliverability with full control over the platform layer.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run Plunk on your own infrastructure. Your SES account handles delivery — you get full-platform functionality at SES prices, nothing 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.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Bounce & Complaint Handling</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Automatic processing of SES bounce and complaint notifications. Plunk keeps your sender reputation clean without any code on your end.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Amazon SES" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
|
||||
|
||||
<SwitchOffer competitorName="Amazon SES" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -169,6 +169,8 @@ export default function BentoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||
|
||||
<SwitchOffer competitorName="Bento" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,6 +193,8 @@ export default function BrevoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||
|
||||
<SwitchOffer competitorName="Brevo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} 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 {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: 'Up to 100 subscribers'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Per-subscriber monthly plans'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: false},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: false},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
|
||||
{feature: 'API Access', plunk: true, competitor: 'Limited'},
|
||||
{feature: 'Custom Domains', plunk: true, competitor: true},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Buttondown over Plunk?',
|
||||
answer:
|
||||
"Choose Buttondown if you're an individual writer or creator who only needs newsletter publishing with a simple, minimal interface. Buttondown is purpose-built for newsletters with a great writing experience. Choose Plunk if you need transactional emails alongside your newsletters, workflow automation, dynamic segmentation, or an open-source platform you can self-host.",
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Buttondown?',
|
||||
answer:
|
||||
"Plunk is pay-as-you-go at $0.001/email — you pay per email sent regardless of list size. Buttondown charges per subscriber: free up to 100, then $9/month for up to 1,000 subscribers, scaling up from there. If you have a large list but send infrequently, Plunk's model is significantly more cost-effective.",
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk replace Buttondown for newsletter publishing?',
|
||||
answer:
|
||||
"Yes. Plunk's marketing campaigns handle newsletter-style broadcasts to your entire list or segments. You get scheduling, tracking (opens, clicks), and unsubscribe management. The main difference is Plunk is built for developers first — if you want a polished writing-focused UI without any code, Buttondown's editor may feel more natural.",
|
||||
},
|
||||
{
|
||||
question: "What does Plunk offer that Buttondown doesn't?",
|
||||
answer:
|
||||
'Plunk adds transactional emails (password resets, receipts, notifications), workflow automation (multi-step sequences triggered by events), dynamic audience segmentation, and a full API for programmatic control. Plunk is also open-source (AGPL-3.0) and self-hostable. Buttondown is focused purely on newsletter publishing with no transactional email support.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ButtondownComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Open-Source Buttondown Alternative: Newsletters + Transactional Emails | Plunk"
|
||||
description="Buttondown is newsletters-only. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with transactional emails, marketing campaigns, and workflow automation in one platform."
|
||||
canonical="https://www.useplunk.com/vs/buttondown"
|
||||
openGraph={{
|
||||
title: 'Open-Source Buttondown Alternative: Newsletters + Transactional Emails | Plunk',
|
||||
description:
|
||||
'Buttondown is newsletters-only. Plunk is open-source, pay-as-you-go, and handles transactional emails, newsletters, and automation in one self-hostable platform.',
|
||||
url: 'https://www.useplunk.com/vs/buttondown',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Buttondown+Alternative&tag=Comparison', alt: 'Plunk vs Buttondown', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Buttondown
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Buttondown
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Buttondown is newsletter-only. Plunk handles newsletters, transactional emails, and workflow automation in one open-source platform — at $0.001 per email, not per subscriber.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
Pay Per Email, Not Per Subscriber
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Your costs should scale with what you send, not the size of your list</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>$0.001 per email. A list of 10,000 subscribers costs nothing until you actually send.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Only pay when you actually send emails</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>No charge for subscriber count</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Transactional, marketing, and automation included</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Buttondown</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Per-subscriber monthly plans</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>$9/month for up to 1,000 subscribers, scaling as your list grows.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Per subscriber</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay based on list size, not sending activity</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Cost grows as your audience grows</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Newsletters only — no transactional emails</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond newsletter publishing</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm: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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Transactional Emails</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send password resets, receipts, notifications, and alerts through the same platform as your newsletters. Buttondown can't do this at all.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Welcome sequences, drip campaigns, re-engagement — Buttondown offers none of this.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Create audience segments that update automatically based on contact data and behavior. Go beyond static lists to target the right people at the right time.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Buttondown is a proprietary closed-source service.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Deploy on your own infrastructure with Docker. Full data ownership, GDPR compliance without relying on third-party processors.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer API</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Full REST API and SDKs for programmatic control. Trigger emails from your backend, track custom events, manage contacts — all without touching the dashboard.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Buttondown" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
|
||||
|
||||
<SwitchOffer competitorName="Buttondown" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,6 +193,8 @@ export default function ConvertkitComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||
|
||||
<SwitchOffer competitorName="ConvertKit" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,6 +189,8 @@ export default function CustomerioComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||
|
||||
<SwitchOffer competitorName="Customer.io" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -85,6 +85,24 @@ const competitors = [
|
||||
description: 'All-in-one platform vs focused email solution',
|
||||
features: ['Transactional Emails', 'Marketing Campaigns', 'API-First'],
|
||||
},
|
||||
{
|
||||
name: 'Mailjet',
|
||||
slug: 'mailjet',
|
||||
description: 'Subscription-based email vs pay-as-you-go open source',
|
||||
features: ['Transactional Emails', 'Marketing Campaigns', 'Workflow Automation'],
|
||||
},
|
||||
{
|
||||
name: 'Buttondown',
|
||||
slug: 'buttondown',
|
||||
description: 'Newsletter publishing vs full email platform',
|
||||
features: ['Newsletters', 'Transactional Emails', 'Pay-as-you-go'],
|
||||
},
|
||||
{
|
||||
name: 'Amazon SES',
|
||||
slug: 'amazon-ses',
|
||||
description: 'Raw email delivery vs complete email platform',
|
||||
features: ['Transactional Emails', 'Marketing Campaigns', 'Self-Hostable'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function CompetitorsIndex() {
|
||||
@@ -182,10 +200,11 @@ export default function CompetitorsIndex() {
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'h-full'}
|
||||
>
|
||||
<Link
|
||||
href={`/vs/${competitor.slug}`}
|
||||
className={'group flex flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-6 sm:p-8 transition hover:border-neutral-900'}
|
||||
className={'group flex h-full flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-6 sm:p-8 transition hover:border-neutral-900'}
|
||||
>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<h3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,6 +170,8 @@ export default function KlaviyoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||
|
||||
<SwitchOffer competitorName="Klaviyo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,6 +193,8 @@ export default function LoopsComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||
|
||||
<SwitchOffer competitorName="Loops" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -195,6 +195,8 @@ export default function MailchimpComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||
|
||||
<SwitchOffer competitorName="Mailchimp" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,6 +170,8 @@ export default function MailerliteComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||
|
||||
<SwitchOffer competitorName="MailerLite" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,6 +189,8 @@ export default function MailgunComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||
|
||||
<SwitchOffer competitorName="Mailgun" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} 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 {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: '200 emails/day (6,000/month)'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Monthly subscription plans'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: false},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: true},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: 'Basic'},
|
||||
{feature: 'Event Tracking', plunk: true, competitor: true},
|
||||
{feature: 'Custom Domains', plunk: true, competitor: true},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Mailjet over Plunk?',
|
||||
answer:
|
||||
'Choose Mailjet if you need inbox preview testing or built-in A/B testing for email campaigns. Mailjet has been around longer and has a broad feature set. Choose Plunk if you need workflow automation, want an open-source platform you can self-host, or prefer pay-as-you-go pricing without monthly subscription commitments.',
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Mailjet?',
|
||||
answer:
|
||||
"Plunk is pure pay-as-you-go at $0.001/email — you pay only for what you send. Mailjet uses monthly subscription tiers: their Essential plan starts around $17/month for 15,000 emails, scaling up from there. For variable sending volumes, Plunk is more cost-effective since you never pay for capacity you don't use.",
|
||||
},
|
||||
{
|
||||
question: 'Is migration from Mailjet to Plunk complex?',
|
||||
answer:
|
||||
"Migration requires updating your API integration since Plunk and Mailjet use different API structures. Core concepts (templates, webhooks, custom domains) map directly. Most teams complete the technical migration in a few hours. Contact lists and templates can be adapted with minimal effort.",
|
||||
},
|
||||
{
|
||||
question: "What does Plunk offer that Mailjet doesn't?",
|
||||
answer:
|
||||
'Plunk adds multi-step workflow automation (triggers, delays, conditions — perfect for onboarding and drip sequences) and is fully open-source under AGPL-3.0, meaning you can self-host on your own infrastructure. Mailjet offers neither workflow automation nor self-hosting. Plunk also uses pure pay-as-you-go pricing rather than tiered subscriptions.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MailjetComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Open-Source Mailjet Alternative: Pay-As-You-Go with Workflow Automation | Plunk"
|
||||
description="Mailjet uses monthly subscription plans and lacks workflow automation. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with transactional, marketing, and automation in one platform."
|
||||
canonical="https://www.useplunk.com/vs/mailjet"
|
||||
openGraph={{
|
||||
title: 'Open-Source Mailjet Alternative: Pay-As-You-Go with Workflow Automation | Plunk',
|
||||
description:
|
||||
'Mailjet uses monthly subscriptions and lacks automation. Plunk is pay-as-you-go, open-source, and self-hostable with transactional, marketing, and automation included.',
|
||||
url: 'https://www.useplunk.com/vs/mailjet',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Mailjet+Alternative&tag=Comparison', alt: 'Plunk vs Mailjet', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Mailjet
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Mailjet
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Mailjet is a solid email platform but locks you into monthly subscriptions and has no workflow automation. Plunk is open-source, self-hostable, pay-as-you-go, and includes automation — no second tool required.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
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>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Only pay for emails you actually send</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Scale up or down without commitment</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Automation and marketing included</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailjet</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Monthly subscription plans</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Tiered monthly plans based on email volume. Essential starts ~$17/month.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Locked into monthly subscription tiers</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay for capacity whether you use it or not</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No workflow automation on any plan</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond what Mailjet offers</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm: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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Onboarding flows, drip campaigns, re-engagement — Mailjet has none of this.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Auto-updating audience segments based on contact data and behavior. Target campaigns with precision beyond Mailjet's basic list management.</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]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Event Tracking</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Track any custom event from your product and use it to trigger workflows or build segments. Connect your app's behavior to your email platform.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Mailjet is a closed, proprietary platform.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your own infrastructure with Docker. Full data sovereignty, compliance-ready, and you only pay AWS SES fees when self-hosting.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Transactional, marketing, and automation under one roof. Pay-as-you-go for all of it — no separate tool for workflows.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<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-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Mailjet" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
|
||||
|
||||
<SwitchOffer competitorName="Mailjet" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -190,6 +190,8 @@ export default function PostmarkComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||
|
||||
<SwitchOffer competitorName="Postmark" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,6 +189,8 @@ export default function ResendComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||
|
||||
<SwitchOffer competitorName="Resend" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,6 +193,8 @@ export default function SendGridComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||
|
||||
<SwitchOffer competitorName="SendGrid" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "^8.5.10",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
|
||||
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'event.triggered':
|
||||
return {
|
||||
icon: Zap,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
||||
description: undefined,
|
||||
badge: {
|
||||
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.sent':
|
||||
return {
|
||||
icon: Send,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-neutral-700',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.delivered':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.opened':
|
||||
return {
|
||||
icon: Eye,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
||||
description:
|
||||
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
||||
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.clicked':
|
||||
return {
|
||||
icon: MousePointerClick,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-sky-700',
|
||||
bgColor: 'bg-sky-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
||||
description:
|
||||
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
||||
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.started':
|
||||
return {
|
||||
icon: Workflow,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
||||
description: `Status: ${String(metadata.status || 'unknown')}`,
|
||||
badge: {
|
||||
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.completed':
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
||||
description: metadata.exitReason
|
||||
? `Exit: ${String(metadata.exitReason)}`
|
||||
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'campaign.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-sky-700',
|
||||
bgColor: 'bg-sky-50',
|
||||
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
||||
description: metadata.subject
|
||||
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
||||
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.email.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
||||
description: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
||||
|
||||
@@ -335,6 +335,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<div className="space-y-4">
|
||||
{domains.map(domain => {
|
||||
const status = getDomainStatus(domain);
|
||||
const mailFromSubdomain = config?.aws?.mailFromSubdomain ?? 'plunk';
|
||||
const mailFromHost = `${mailFromSubdomain}.${domain.domain}`;
|
||||
return (
|
||||
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -494,8 +496,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mb-2">
|
||||
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
|
||||
handle bounces/complaints.
|
||||
Set up a custom MAIL FROM domain ({mailFromHost}) to improve deliverability and handle
|
||||
bounces/complaints.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -522,16 +524,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
plunk.{domain.domain}
|
||||
{mailFromHost}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||
onClick={() => handleCopyToken(mailFromHost, 3000)}
|
||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||
>
|
||||
<AnimatedCopyIcon
|
||||
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
|
||||
isCopied={copiedToken === `${mailFromHost}-3000`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -571,16 +573,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<td className="py-3 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||
plunk.{domain.domain}
|
||||
{mailFromHost}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||
onClick={() => handleCopyToken(mailFromHost, 3001)}
|
||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||
>
|
||||
<AnimatedCopyIcon
|
||||
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
|
||||
isCopied={copiedToken === `${mailFromHost}-3001`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -306,21 +306,31 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
iframeDoc.write(fullHtml);
|
||||
iframeDoc.close();
|
||||
|
||||
// Auto-adjust iframe height to content
|
||||
// Auto-adjust iframe height to content. Reset to a small value first so
|
||||
// body content that uses % / vh heights doesn't lock the iframe to its
|
||||
// previous size (which would otherwise cause the iframe to grow by the
|
||||
// padding offset on every preview-device switch).
|
||||
const adjustHeight = () => {
|
||||
if (iframe.contentWindow) {
|
||||
const height = iframe.contentWindow.document.body.scrollHeight;
|
||||
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
||||
}
|
||||
if (!iframe.contentWindow) return;
|
||||
iframe.style.height = '0px';
|
||||
const doc = iframe.contentWindow.document;
|
||||
const height = Math.max(
|
||||
doc.body?.scrollHeight ?? 0,
|
||||
doc.documentElement?.scrollHeight ?? 0,
|
||||
);
|
||||
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
||||
};
|
||||
|
||||
// Adjust height after content loads
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.addEventListener('load', adjustHeight);
|
||||
// Also adjust immediately for already-loaded content
|
||||
setTimeout(adjustHeight, 100);
|
||||
setTimeout(adjustHeight, 300); // Fallback for slow-loading images
|
||||
}
|
||||
const timeouts = [
|
||||
window.setTimeout(adjustHeight, 100),
|
||||
window.setTimeout(adjustHeight, 300),
|
||||
];
|
||||
iframe.contentWindow?.addEventListener('load', adjustHeight);
|
||||
|
||||
return () => {
|
||||
timeouts.forEach(window.clearTimeout);
|
||||
iframe.contentWindow?.removeEventListener('load', adjustHeight);
|
||||
};
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -33,7 +33,7 @@ function HelpResources() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<div className="px-6 pb-6 pt-4 border-t border-neutral-200">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
@@ -101,12 +101,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
|
||||
if (isLoading || !setupState) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>Get started with Plunk in minutes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div
|
||||
@@ -122,8 +122,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
<HelpResources />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -217,12 +217,12 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
// If core setup is complete and they're actively sending, show success message
|
||||
if (setupState.hasVerifiedDomain && hasContacts && hasSentCampaign && hasRecentCampaign) {
|
||||
return (
|
||||
<Card>
|
||||
<Card className="flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>Your project is fully set up</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex items-start gap-4 p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="h-10 w-10 rounded-lg bg-green-100 border border-green-200 flex items-center justify-center flex-shrink-0">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-700" />
|
||||
@@ -234,8 +234,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
<HelpResources />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -244,14 +244,14 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
const visibleSteps = allSteps.slice(0, 3);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Start</CardTitle>
|
||||
<CardDescription>
|
||||
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-3">
|
||||
{visibleSteps.map(step => {
|
||||
const Icon = step.icon;
|
||||
@@ -281,8 +281,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<HelpResources />
|
||||
</CardContent>
|
||||
<HelpResources />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface TemplateSearchPickerProps {
|
||||
export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) {
|
||||
const [query, setQuery] = useState(initialName ?? '');
|
||||
const [prevInitialName, setPrevInitialName] = useState(initialName);
|
||||
const [selectedName, setSelectedName] = useState(initialName ?? '');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -29,6 +30,7 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
|
||||
if (initialName !== prevInitialName) {
|
||||
setPrevInitialName(initialName);
|
||||
setQuery(initialName ?? '');
|
||||
if (initialName) setSelectedName(initialName);
|
||||
}
|
||||
|
||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -49,7 +51,7 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
|
||||
// When closed, show the selected template's name rather than the raw query
|
||||
const displayValue = open
|
||||
? query
|
||||
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');
|
||||
: (value ? (data?.data.find(t => t.id === value)?.name ?? selectedName ?? initialName ?? '') : '');
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -85,6 +87,7 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
|
||||
onSelect={() => {
|
||||
onChange(t.id);
|
||||
setQuery(t.name);
|
||||
setSelectedName(t.name);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css';
|
||||
import type {WorkflowStep} from '@plunk/db';
|
||||
import {
|
||||
Clock,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Hourglass,
|
||||
Lightbulb,
|
||||
@@ -243,7 +244,7 @@ function CustomNode({
|
||||
bgColor?: string;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
template?: {name: string};
|
||||
template?: {id: string; name: string};
|
||||
config?: any;
|
||||
};
|
||||
}) {
|
||||
@@ -341,10 +342,19 @@ function CustomNode({
|
||||
{/* Details */}
|
||||
{data.template && (
|
||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span className="truncate">{data.template.name}</span>
|
||||
</div>
|
||||
<a
|
||||
href={`/templates/${data.template.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
className="nodrag flex items-center gap-2 text-xs text-neutral-600 hover:text-blue-600 hover:bg-blue-50 -mx-2 px-2 py-1 rounded transition-colors group/template"
|
||||
title="Open template in a new tab"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate flex-1">{data.template.name}</span>
|
||||
<ExternalLink className="h-3 w-3 shrink-0 opacity-0 group-hover/template:opacity-100 transition-opacity" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.type === 'DELAY' && data.config?.amount && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
|
||||
import {ExternalLink} from 'lucide-react';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
@@ -68,7 +69,20 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="editTemplate">Email Template</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="editTemplate">Email Template</Label>
|
||||
{templateId && (
|
||||
<a
|
||||
href={`/templates/${templateId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
Edit template
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
|
||||
import {useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
@@ -5,31 +6,50 @@ import {KeyValueEditor} from '../KeyValueEditor';
|
||||
|
||||
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
|
||||
|
||||
type SubscriptionAction = 'none' | 'subscribe' | 'unsubscribe';
|
||||
|
||||
const SUBSCRIPTION_OPTIONS: Array<{value: SubscriptionAction; label: string; description: string}> = [
|
||||
{value: 'none', label: 'Leave as is', description: "Don't change the contact's subscription state."},
|
||||
{value: 'subscribe', label: 'Subscribe', description: 'Mark the contact as subscribed.'},
|
||||
{value: 'unsubscribe', label: 'Unsubscribe', description: 'Mark the contact as unsubscribed.'},
|
||||
];
|
||||
|
||||
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
|
||||
const config = getStepConfig(step);
|
||||
const initialUpdates =
|
||||
config.updates && typeof config.updates === 'object'
|
||||
? (config.updates as Record<string, string | number | boolean>)
|
||||
: null;
|
||||
const initialSubscriptionAction: SubscriptionAction =
|
||||
config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe'
|
||||
? config.subscriptionAction
|
||||
: 'none';
|
||||
|
||||
const [name, setName] = useState(step.name);
|
||||
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
|
||||
initialUpdates,
|
||||
);
|
||||
const [subscriptionAction, setSubscriptionAction] = useState<SubscriptionAction>(initialSubscriptionAction);
|
||||
|
||||
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
|
||||
toast.error('At least one field to update is required');
|
||||
const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0;
|
||||
const hasSubscriptionAction = subscriptionAction !== 'none';
|
||||
|
||||
if (!hasUpdates && !hasSubscriptionAction) {
|
||||
toast.error('Add at least one field to update or choose a subscription action');
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await update({
|
||||
name,
|
||||
config: {updates: contactUpdateData},
|
||||
config: {
|
||||
updates: hasUpdates ? contactUpdateData : {},
|
||||
subscriptionAction,
|
||||
},
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
@@ -48,7 +68,32 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
>
|
||||
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
||||
<div className="space-y-2">
|
||||
<Label>Subscription state</Label>
|
||||
<RadioGroup
|
||||
value={subscriptionAction}
|
||||
onValueChange={value => setSubscriptionAction(value as SubscriptionAction)}
|
||||
className="gap-2"
|
||||
>
|
||||
{SUBSCRIPTION_OPTIONS.map(option => (
|
||||
<label
|
||||
key={option.value}
|
||||
htmlFor={`subscriptionAction-${option.value}`}
|
||||
className="flex items-start gap-3 rounded-md border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50"
|
||||
>
|
||||
<RadioGroupItem id={`subscriptionAction-${option.value}`} value={option.value} className="mt-0.5" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm font-medium text-neutral-900">{option.label}</div>
|
||||
<div className="text-xs text-neutral-500">{option.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
||||
</div>
|
||||
</StepDialogShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ConfigResponse {
|
||||
};
|
||||
aws: {
|
||||
sesRegion: string;
|
||||
mailFromSubdomain: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
Info,
|
||||
Mail,
|
||||
MousePointer,
|
||||
Save,
|
||||
@@ -107,6 +108,7 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
const [editedCampaign, setEditedCampaign] = useState<Partial<Campaign>>({});
|
||||
const [scheduledDateTime, setScheduledDateTime] = useState('');
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [testEmailAddress, setTestEmailAddress] = useState('');
|
||||
|
||||
type CampaignDialog =
|
||||
@@ -178,6 +180,7 @@ export default function CampaignDetailsPage() {
|
||||
toast.success(`Campaign scheduled for ${localTimeString}`);
|
||||
setDialog({type: 'none'});
|
||||
setScheduledDateTime('');
|
||||
setSelectedPreset(null);
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||
@@ -436,126 +439,35 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaign Settings - Horizontal Layout */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Campaign Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Settings</CardTitle>
|
||||
<CardDescription>Basic information about your campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Campaign Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={editedCampaign.name || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
|
||||
required
|
||||
placeholder="Spring Sale Campaign"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
type="text"
|
||||
value={editedCampaign.description || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
|
||||
placeholder="Optional description for internal use"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Campaign Type</Label>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{([
|
||||
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
||||
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||
] as const).map(({value, label, description}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
|
||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||
(editedCampaign.type ?? c.type) === value
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
|
||||
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{unsubscribeUrl}}'}
|
||||
</code>
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{manageUrl}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={editedCampaign.subject || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
|
||||
required
|
||||
placeholder="Introducing our Spring Sale!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={editedCampaign.from || ''}
|
||||
fromName={editedCampaign.fromName || ''}
|
||||
replyTo={editedCampaign.replyTo || ''}
|
||||
onFromChange={value => setEditedCampaign({...editedCampaign, from: value})}
|
||||
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
|
||||
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audience Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
{/* Audience — surfaced first because Send lives in the header.
|
||||
Users need to see who/how many before pressing Send. */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
|
||||
<div>
|
||||
<CardTitle>Audience</CardTitle>
|
||||
<CardDescription>Who will receive this campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="audienceType">Audience Type *</Label>
|
||||
<CardDescription>Who will receive this campaign when you send</CardDescription>
|
||||
</div>
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 shrink-0">
|
||||
<Users className="h-4 w-4 text-neutral-500" />
|
||||
<span className="text-sm font-semibold text-neutral-900 tabular-nums">
|
||||
{draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'recipient' : 'recipients'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audienceType">
|
||||
Audience Type <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={editedCampaign.audienceType ?? c.audienceType}
|
||||
onValueChange={(value: CampaignAudienceType) => {
|
||||
setEditedCampaign({
|
||||
...editedCampaign,
|
||||
audienceType: value,
|
||||
// Clear segmentId if changing away from SEGMENT
|
||||
segmentId: value === CampaignAudienceType.SEGMENT ? editedCampaign.segmentId : undefined,
|
||||
});
|
||||
}}
|
||||
@@ -571,7 +483,7 @@ export default function CampaignDetailsPage() {
|
||||
/>
|
||||
<SelectItemWithDescription
|
||||
value={CampaignAudienceType.SEGMENT}
|
||||
title="Segment"
|
||||
title="Specific Segment"
|
||||
description="Target a defined group of contacts"
|
||||
/>
|
||||
</SelectContent>
|
||||
@@ -579,8 +491,10 @@ export default function CampaignDetailsPage() {
|
||||
</div>
|
||||
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
|
||||
<div>
|
||||
<Label htmlFor="segment">Select Segment *</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="segment">
|
||||
Select Segment <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
|
||||
onValueChange={(value: string) => {
|
||||
@@ -610,44 +524,160 @@ export default function CampaignDetailsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{segments && segments.length === 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
No segments found.{' '}
|
||||
<Link href="/segments/new" className="underline">
|
||||
Create one first
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Filtered audiences are configured with advanced filter conditions
|
||||
</p>
|
||||
)}
|
||||
{editedCampaign.audienceType === CampaignAudienceType.FILTERED && (
|
||||
<p className="text-sm text-neutral-500">
|
||||
Filtered audiences are configured with advanced filter conditions
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Show recipient count */}
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="mt-4 p-3 bg-neutral-50 border border-neutral-200 rounded-lg space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{draftRecipientCount.toLocaleString()} recipients
|
||||
</span>
|
||||
{draftRecipientCount > 0 && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
Recalculated at send time. Final count may differ if contacts{' '}
|
||||
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||
? 'are added or removed, or segment membership changes.'
|
||||
: 'subscribe, unsubscribe, or segment membership changes.'
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Row 1: Basic Info + Campaign Type */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>Name and describe your campaign</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Campaign Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Spring Sale Announcement"
|
||||
value={editedCampaign.name || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, name: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="Internal notes about this campaign"
|
||||
value={editedCampaign.description || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, description: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Campaign Type */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Campaign Type</CardTitle>
|
||||
<CardDescription>Choose how this campaign should be treated</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{([
|
||||
{value: TemplateType.MARKETING, label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
||||
{value: TemplateType.TRANSACTIONAL, label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||
{value: TemplateType.HEADLESS, label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||
] as const).map(({value, label, description}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setEditedCampaign({...editedCampaign, type: value})}
|
||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||
(editedCampaign.type ?? c.type) === value
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(editedCampaign.type ?? c.type) === TemplateType.HEADLESS &&
|
||||
!detectUnsubscribeSignal(editedCampaign.body ?? c.body) && (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{unsubscribeUrl}}'}
|
||||
</code>
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{manageUrl}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 pl-6">
|
||||
Recalculated at send time. Final count may differ if contacts{' '}
|
||||
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||
? 'are added or removed, or segment membership changes.'
|
||||
: 'subscribe, unsubscribe, or segment membership changes.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Email Editor - Full Width */}
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Settings</CardTitle>
|
||||
<CardDescription>Configure sender information and subject</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<EmailSettings
|
||||
from={editedCampaign.from || ''}
|
||||
fromName={editedCampaign.fromName || ''}
|
||||
replyTo={editedCampaign.replyTo || ''}
|
||||
onFromChange={value => setEditedCampaign({...editedCampaign, from: value})}
|
||||
onFromNameChange={value => setEditedCampaign({...editedCampaign, fromName: value})}
|
||||
onReplyToChange={value => setEditedCampaign({...editedCampaign, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">
|
||||
Email Subject <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
placeholder="e.g., Introducing our Spring Sale!"
|
||||
value={editedCampaign.subject || ''}
|
||||
onChange={e => setEditedCampaign({...editedCampaign, subject: e.target.value})}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Content */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Content</CardTitle>
|
||||
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
|
||||
<CardDescription>Design your email message</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
@@ -662,37 +692,51 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
{/* Test Email Dialog */}
|
||||
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send Test Email</DialogTitle>
|
||||
<DialogTitle>Send a preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a test version of this campaign to a project member to verify how it looks. The test email will
|
||||
be prefixed with [TEST] in the subject line.
|
||||
Get a copy of this campaign in your inbox before sending it for real.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="testEmail">Project Member</Label>
|
||||
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
||||
<SelectTrigger id="testEmail" className="mt-2">
|
||||
<SelectValue placeholder="Select a project member..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectMembers?.data.map(member => (
|
||||
<SelectItem key={member.userId} value={member.email}>
|
||||
{member.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
For security reasons, test emails can only be sent to project members.
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testEmail">Send to</Label>
|
||||
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
|
||||
<SelectTrigger id="testEmail">
|
||||
<SelectValue placeholder="Choose a teammate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectMembers?.data.map(member => (
|
||||
<SelectItem key={member.userId} value={member.email}>
|
||||
{member.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Preview of how the email will arrive */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-neutral-500">Will arrive as</Label>
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200 text-sm">
|
||||
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
|
||||
<span className="text-neutral-500">From</span>
|
||||
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[64px_1fr] gap-3 px-3 py-2.5">
|
||||
<span className="text-neutral-500">Subject</span>
|
||||
<span className="text-neutral-900 truncate">
|
||||
<span className="font-medium">[TEST]</span> {editedCampaign.subject || c.subject}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
Variables like {'{{firstName}}'} aren{"'"}t replaced in previews. You{"'"}ll see them as written.
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -709,7 +753,8 @@ export default function CampaignDetailsPage() {
|
||||
onClick={handleSendTestEmail}
|
||||
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
|
||||
>
|
||||
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
|
||||
<TestTube className="h-4 w-4" />
|
||||
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -717,92 +762,105 @@ export default function CampaignDetailsPage() {
|
||||
|
||||
{/* Schedule Dialog */}
|
||||
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule Campaign</DialogTitle>
|
||||
<DialogTitle>Schedule for later</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
|
||||
)
|
||||
Pick a time and Plunk will send it for you. Times shown in {getUserTimezone()}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Quick Presets */}
|
||||
<div>
|
||||
<Label>Quick Schedule</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneHour())}
|
||||
>
|
||||
In 1 hour
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inThreeHours())}
|
||||
>
|
||||
In 3 hours
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt9AM())}
|
||||
>
|
||||
Tomorrow at 9 AM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.tomorrowAt2PM())}
|
||||
>
|
||||
Tomorrow at 2 PM
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.nextMonday())}
|
||||
>
|
||||
Next Monday
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScheduledDateTime(schedulePresets.inOneWeek())}
|
||||
>
|
||||
In 1 week
|
||||
</Button>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Quick presets */}
|
||||
<div className="space-y-2">
|
||||
<Label>Quick options</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{key: 'in1h', label: 'In 1 hour', getValue: schedulePresets.inOneHour},
|
||||
{key: 'in3h', label: 'In 3 hours', getValue: schedulePresets.inThreeHours},
|
||||
{key: 'tom9', label: 'Tomorrow, 9 AM', getValue: schedulePresets.tomorrowAt9AM},
|
||||
{key: 'tom2', label: 'Tomorrow, 2 PM', getValue: schedulePresets.tomorrowAt2PM},
|
||||
{key: 'nextMon', label: 'Next Monday', getValue: schedulePresets.nextMonday},
|
||||
{key: 'in1w', label: 'In 1 week', getValue: schedulePresets.inOneWeek},
|
||||
].map(({key, label, getValue}) => {
|
||||
const isActive = selectedPreset === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setScheduledDateTime(getValue());
|
||||
setSelectedPreset(key);
|
||||
}}
|
||||
className={`min-h-[40px] px-3 py-2 rounded-lg border text-sm text-left transition-colors ${
|
||||
isActive
|
||||
? 'border-neutral-900 bg-neutral-50 text-neutral-900 font-medium'
|
||||
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Date/Time */}
|
||||
<div>
|
||||
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduledDateTime">Or pick an exact time</Label>
|
||||
<Input
|
||||
id="scheduledDateTime"
|
||||
type="datetime-local"
|
||||
value={scheduledDateTime}
|
||||
onChange={e => setScheduledDateTime(e.target.value)}
|
||||
onChange={e => {
|
||||
setScheduledDateTime(e.target.value);
|
||||
setSelectedPreset(null);
|
||||
}}
|
||||
min={new Date().toISOString().slice(0, 16)}
|
||||
className="mt-2"
|
||||
/>
|
||||
{scheduledDateTime && (
|
||||
<div className="mt-2 p-3 bg-neutral-50 border border-neutral-200 rounded-lg">
|
||||
<p className="text-xs font-medium text-neutral-500 mb-1">Scheduled for:</p>
|
||||
<p className="text-sm font-medium text-neutral-900">{formatFullDateTime(new Date(scheduledDateTime))}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
|
||||
</div>
|
||||
|
||||
{/* Confirmation preview — date + audience together */}
|
||||
{scheduledDateTime && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-50 divide-y divide-neutral-200">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-neutral-500">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide">Sending on</span>
|
||||
</div>
|
||||
<p className="mt-1 text-base font-semibold text-neutral-900">
|
||||
{formatFullDateTime(new Date(scheduledDateTime))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draftRecipientCount > 0 && (
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-neutral-500">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide">To</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-900">
|
||||
<span className="font-semibold tabular-nums">{draftRecipientCount.toLocaleString()}</span>
|
||||
<span className="text-neutral-600">
|
||||
{draftRecipientCount === 1 ? ' recipient in ' : ' recipients in '}
|
||||
</span>
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
|
||||
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||
? 'all contacts'
|
||||
: 'all subscribed contacts')}
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
|
||||
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'the selected segment')}
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'filtered contacts'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
You can edit or cancel this campaign anytime before it sends.
|
||||
</p>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -810,12 +868,14 @@ export default function CampaignDetailsPage() {
|
||||
onClick={() => {
|
||||
setDialog({type: 'none'});
|
||||
setScheduledDateTime('');
|
||||
setSelectedPreset(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
Not yet
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSchedule}>
|
||||
Schedule Campaign
|
||||
<Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Schedule send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -825,15 +885,66 @@ export default function CampaignDetailsPage() {
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={dialog.type === 'send'}
|
||||
onOpenChange={open => !open && setDialog({type: 'none'})}
|
||||
onConfirm={handleSend}
|
||||
title="Send Campaign"
|
||||
description="Are you sure you want to send this campaign now? This action cannot be undone."
|
||||
confirmText="Send Now"
|
||||
variant="default"
|
||||
/>
|
||||
<Dialog open={dialog.type === 'send'} onOpenChange={open => !open && setDialog({type: 'none'})}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ready to send?</DialogTitle>
|
||||
<DialogDescription>Review the details below, then send when you{"'"}re ready.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Hero: recipient count */}
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50 px-5 py-6 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-neutral-500">
|
||||
<Users className="h-4 w-4" />
|
||||
<span className="text-xs font-medium uppercase tracking-wide">Recipients</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-4xl font-bold text-neutral-900 tabular-nums">
|
||||
{draftRecipientCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-neutral-500">
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.ALL &&
|
||||
((editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
|
||||
? 'All contacts'
|
||||
: 'All subscribed contacts')}
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT &&
|
||||
(segments?.find(s => s.id === (editedCampaign.segmentId ?? c.segmentId))?.name ?? 'Selected segment')}
|
||||
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.FILTERED && 'Filtered contacts'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compact summary */}
|
||||
<div className="rounded-lg border border-neutral-200 divide-y divide-neutral-200 text-sm">
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
|
||||
<span className="text-neutral-500">From</span>
|
||||
<span className="text-neutral-900 truncate">{editedCampaign.from || c.from}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[80px_1fr] gap-3 px-3 py-2.5">
|
||||
<span className="text-neutral-500">Subject</span>
|
||||
<span className="text-neutral-900 truncate">{editedCampaign.subject || c.subject}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reassurance */}
|
||||
<div className="flex items-start gap-2 rounded-lg bg-neutral-50 px-3 py-2.5">
|
||||
<Info className="h-4 w-4 text-neutral-500 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-neutral-600 leading-relaxed">
|
||||
Sending takes a few minutes. You can cancel the campaign at any time while it{"'"}s still sending.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialog({type: 'none'})}>
|
||||
Not yet
|
||||
</Button>
|
||||
<Button onClick={async () => { await handleSend(); setDialog({type: 'none'}); }}>
|
||||
<Send className="h-4 w-4" />
|
||||
Send to {draftRecipientCount.toLocaleString()} {draftRecipientCount === 1 ? 'contact' : 'contacts'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={dialog.type === 'delete'}
|
||||
|
||||
@@ -8,11 +8,7 @@ import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign, Template} from '@plunk/db';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
@@ -23,11 +19,11 @@ import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog'
|
||||
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
|
||||
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Search, Trash2, X} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -35,7 +31,9 @@ import dayjs from 'dayjs';
|
||||
export default function CampaignsPage() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL');
|
||||
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
@@ -44,10 +42,18 @@ export default function CampaignsPage() {
|
||||
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
|
||||
|
||||
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
|
||||
`/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||
`/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
|
||||
DRAFT: {label: 'Draft', variant: 'neutral'},
|
||||
@@ -245,21 +251,45 @@ export default function CampaignsPage() {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="w-56">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALL">All Statuses</SelectItem>
|
||||
<SelectItem value="DRAFT">Draft</SelectItem>
|
||||
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
|
||||
<SelectItem value="SENDING">Sending</SelectItem>
|
||||
<SelectItem value="SENT">Sent</SelectItem>
|
||||
<SelectItem value="CANCELLED">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Search & Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search campaigns..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearch('');
|
||||
setPage(1);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1.5 shrink-0 flex-wrap">
|
||||
{(['ALL', 'DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'] as const).map(status => (
|
||||
<Button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => { setStatusFilter(status); setPage(1); }}
|
||||
variant={statusFilter === status ? 'default' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{status === 'ALL' ? 'All' : status.charAt(0) + status.slice(1).toLowerCase()}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campaigns List */}
|
||||
@@ -275,66 +305,59 @@ export default function CampaignsPage() {
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||
title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
|
||||
description={
|
||||
statusFilter !== 'ALL'
|
||||
? 'Adjust your filters or create a new campaign.'
|
||||
: 'Send one-off emails to groups of contacts.'
|
||||
search
|
||||
? 'Try a different search term.'
|
||||
: statusFilter !== 'ALL'
|
||||
? 'Adjust your filters or create a new campaign.'
|
||||
: 'Send one-off emails to groups of contacts.'
|
||||
}
|
||||
action={
|
||||
statusFilter === 'ALL' ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-80">
|
||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">Empty Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Start from scratch with a blank canvas
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Template</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Use an existing template as a starting point
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Copy content and settings from an existing campaign
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link href="/campaigns/create">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Campaign
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-80">
|
||||
<DropdownMenuItem asChild className="py-3 cursor-pointer">
|
||||
<Link href="/campaigns/create" className="flex items-start gap-3">
|
||||
<Mail className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">Empty Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Start from scratch with a blank canvas
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowTemplateDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Template</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Use an existing template as a starting point
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setShowCampaignDialog(true)} className="py-3 cursor-pointer">
|
||||
<div className="flex items-start gap-3">
|
||||
<RefreshCw className="h-4 w-4 mt-0.5 text-neutral-700" />
|
||||
<div className="flex flex-col gap-0.5 flex-1">
|
||||
<span className="font-medium text-sm">From Previous Campaign</span>
|
||||
<span className="text-xs text-neutral-500 leading-snug">
|
||||
Copy content and settings from an existing campaign
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -2,9 +2,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
ConfirmDialog,
|
||||
Dialog,
|
||||
@@ -25,14 +22,18 @@ import {KeyValueEditor} from '../../components/KeyValueEditor';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
FileUp,
|
||||
Loader2,
|
||||
Mail,
|
||||
MailCheck,
|
||||
MailX,
|
||||
Minus,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
@@ -61,6 +62,8 @@ export default function ContactsPage() {
|
||||
const [contactToDelete, setContactToDelete] = useState<string | null>(null);
|
||||
const [totalCount, setTotalCount] = useState<number>(0);
|
||||
const [selectedContacts, setSelectedContacts] = useState<Set<string>>(new Set());
|
||||
const [selectAllMatching, setSelectAllMatching] = useState(false);
|
||||
const [excludedContacts, setExcludedContacts] = useState<Set<string>>(new Set());
|
||||
const [showBulkActionsDialog, setShowBulkActionsDialog] = useState(false);
|
||||
const [bulkOperation, setBulkOperation] = useState<'subscribe' | 'unsubscribe' | 'delete' | null>(null);
|
||||
const pageSize = 50;
|
||||
@@ -87,6 +90,9 @@ export default function ContactsPage() {
|
||||
setCursorHistory([undefined]);
|
||||
setCurrentPage(0);
|
||||
setContacts([]);
|
||||
setSelectedContacts(new Set());
|
||||
setSelectAllMatching(false);
|
||||
setExcludedContacts(new Set());
|
||||
}, 350);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput, search]);
|
||||
@@ -96,9 +102,12 @@ export default function ContactsPage() {
|
||||
const newPage = currentPage + 1;
|
||||
setCursor(data.cursor);
|
||||
setCurrentPage(newPage);
|
||||
setSelectedContacts(new Set()); // Clear selection on page change
|
||||
// Preserve selection across pages only when "select all matching" is on; otherwise
|
||||
// clear, since per-page id sets stop being meaningful once you've left the page.
|
||||
if (!selectAllMatching) {
|
||||
setSelectedContacts(new Set());
|
||||
}
|
||||
|
||||
// Store cursor in history if not already there
|
||||
if (cursorHistory.length <= newPage) {
|
||||
setCursorHistory(prev => [...prev, data.cursor]);
|
||||
}
|
||||
@@ -111,12 +120,38 @@ export default function ContactsPage() {
|
||||
const previousCursor = cursorHistory[newPage];
|
||||
setCursor(previousCursor);
|
||||
setCurrentPage(newPage);
|
||||
setSelectedContacts(new Set()); // Clear selection on page change
|
||||
if (!selectAllMatching) {
|
||||
setSelectedContacts(new Set());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// True when the current page's checkbox should appear "all selected"
|
||||
const allOnPageSelected = contacts.length > 0 && (
|
||||
selectAllMatching
|
||||
? contacts.every(c => !excludedContacts.has(c.id))
|
||||
: selectedContacts.size === contacts.length && contacts.every(c => selectedContacts.has(c.id))
|
||||
);
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedContacts.size === contacts.length && contacts.length > 0) {
|
||||
if (selectAllMatching) {
|
||||
// Toggle: exclude or re-include all on this page
|
||||
if (allOnPageSelected) {
|
||||
setExcludedContacts(prev => {
|
||||
const next = new Set(prev);
|
||||
contacts.forEach(c => next.add(c.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setExcludedContacts(prev => {
|
||||
const next = new Set(prev);
|
||||
contacts.forEach(c => next.delete(c.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (allOnPageSelected) {
|
||||
setSelectedContacts(new Set());
|
||||
} else {
|
||||
setSelectedContacts(new Set(contacts.map(c => c.id)));
|
||||
@@ -124,15 +159,30 @@ export default function ContactsPage() {
|
||||
};
|
||||
|
||||
const handleSelectContact = (contactId: string) => {
|
||||
const newSelected = new Set(selectedContacts);
|
||||
if (newSelected.has(contactId)) {
|
||||
newSelected.delete(contactId);
|
||||
} else {
|
||||
newSelected.add(contactId);
|
||||
if (selectAllMatching) {
|
||||
setExcludedContacts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(contactId)) next.delete(contactId);
|
||||
else next.add(contactId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelectedContacts(newSelected);
|
||||
setSelectedContacts(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(contactId)) next.delete(contactId);
|
||||
else next.add(contactId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isContactSelected = (contactId: string) =>
|
||||
selectAllMatching ? !excludedContacts.has(contactId) : selectedContacts.has(contactId);
|
||||
|
||||
const effectiveSelectionCount = selectAllMatching
|
||||
? Math.max(0, totalCount - excludedContacts.size)
|
||||
: selectedContacts.size;
|
||||
|
||||
const handleBulkAction = (operation: 'subscribe' | 'unsubscribe' | 'delete') => {
|
||||
setBulkOperation(operation);
|
||||
setShowBulkActionsDialog(true);
|
||||
@@ -140,6 +190,14 @@ export default function ContactsPage() {
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedContacts(new Set());
|
||||
setSelectAllMatching(false);
|
||||
setExcludedContacts(new Set());
|
||||
};
|
||||
|
||||
const handleSelectAllMatching = () => {
|
||||
setSelectAllMatching(true);
|
||||
setSelectedContacts(new Set());
|
||||
setExcludedContacts(new Set());
|
||||
};
|
||||
|
||||
const promptDelete = (contactId: string) => {
|
||||
@@ -172,8 +230,7 @@ export default function ContactsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Contacts</h1>
|
||||
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
|
||||
Manage your email subscribers and their data.{' '}
|
||||
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
|
||||
Manage your email subscribers and their data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -191,45 +248,68 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearch('');
|
||||
setCursor(undefined);
|
||||
setCursorHistory([undefined]);
|
||||
setCurrentPage(0);
|
||||
setContacts([]);
|
||||
}}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
{selectedContacts.size > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{selectedContacts.size} contact{selectedContacts.size !== 1 ? 's' : ''} selected
|
||||
{/* Contacts Table */}
|
||||
<Card>
|
||||
{/* Contextual header strip: idle = search + count, selecting = bulk actions.
|
||||
Single fixed-min-height row prevents layout shift as state toggles.
|
||||
The select-all-matching link is folded inline into the toolbar. */}
|
||||
<div
|
||||
key={effectiveSelectionCount === 0 ? 'idle' : 'selecting'}
|
||||
className="border-b border-neutral-200 px-6 min-h-[68px] flex items-center py-3 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-150"
|
||||
>
|
||||
{effectiveSelectionCount === 0 ? (
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by email..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
className="pl-10 pr-9 h-10"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearch('');
|
||||
setCursor(undefined);
|
||||
setCursorHistory([undefined]);
|
||||
setCurrentPage(0);
|
||||
setContacts([]);
|
||||
}}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded-sm p-0.5 text-neutral-400 transition-colors hover:text-neutral-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span className="hidden sm:inline text-sm text-neutral-500 tabular-nums whitespace-nowrap">
|
||||
{totalCount.toLocaleString()} {search ? 'matching' : 'total'}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<div className="flex items-center gap-x-4 gap-y-2 min-w-0 flex-wrap">
|
||||
<span className="text-sm font-medium text-neutral-900 tabular-nums whitespace-nowrap">
|
||||
{effectiveSelectionCount.toLocaleString()} selected
|
||||
</span>
|
||||
{!selectAllMatching && allOnPageSelected && totalCount > contacts.length && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllMatching}
|
||||
className="text-sm font-medium text-neutral-600 underline-offset-4 transition-colors hover:text-neutral-900 hover:underline focus-visible:outline-none focus-visible:underline focus-visible:text-neutral-900 whitespace-nowrap rounded-sm tabular-nums"
|
||||
>
|
||||
Select all {totalCount.toLocaleString()}
|
||||
{search ? ' matching' : ''}
|
||||
</button>
|
||||
)}
|
||||
<div className="hidden sm:block h-5 w-px bg-neutral-200" aria-hidden="true" />
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
|
||||
<MailCheck className="h-4 w-4 mr-1.5" />
|
||||
Subscribe
|
||||
@@ -238,48 +318,50 @@ export default function ContactsPage() {
|
||||
<MailX className="h-4 w-4 mr-1.5" />
|
||||
Unsubscribe
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="text-neutral-700 transition-colors hover:bg-red-50 hover:text-red-700 hover:border-red-200"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={clearSelection}>
|
||||
Clear Selection
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
className="text-neutral-500 hover:text-neutral-900"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Contacts Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Contacts</CardTitle>
|
||||
<CardDescription>
|
||||
View and manage your contact list.
|
||||
{totalCount > 0 && ` ${totalCount.toLocaleString()} total contacts`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
{isLoading && contacts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<IconSpinner />
|
||||
</div>
|
||||
) : contacts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title={search ? 'No contacts match' : 'No contacts yet'}
|
||||
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
||||
action={
|
||||
!search ? (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="px-6 py-12">
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title={search ? 'No contacts match' : 'No contacts yet'}
|
||||
description={search ? 'Try a different search term.' : 'Add contacts to start tracking engagement.'}
|
||||
action={
|
||||
!search ? (
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table View - Hidden on mobile */}
|
||||
@@ -289,7 +371,7 @@ export default function ContactsPage() {
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left w-12">
|
||||
<Checkbox
|
||||
checked={selectedContacts.size === contacts.length && contacts.length > 0}
|
||||
checked={allOnPageSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
@@ -312,7 +394,7 @@ export default function ContactsPage() {
|
||||
<tr key={contact.id} className="hover:bg-neutral-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Checkbox
|
||||
checked={selectedContacts.has(contact.id)}
|
||||
checked={isContactSelected(contact.id)}
|
||||
onCheckedChange={() => handleSelectContact(contact.id)}
|
||||
/>
|
||||
</td>
|
||||
@@ -360,7 +442,7 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View - Only visible on mobile */}
|
||||
<div className="md:hidden space-y-3">
|
||||
<div className="md:hidden space-y-3 p-4">
|
||||
{contacts.map(contact => (
|
||||
<div
|
||||
key={contact.id}
|
||||
@@ -405,7 +487,7 @@ export default function ContactsPage() {
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{(currentPage > 0 || data?.hasMore) && (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-6 pt-6 border-t border-neutral-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 px-6 py-4 border-t border-neutral-200">
|
||||
<div className="text-xs sm:text-sm text-neutral-600 text-center sm:text-left">
|
||||
Showing <span className="font-medium text-neutral-900">{currentPage * pageSize + 1}</span> to{' '}
|
||||
<span className="font-medium text-neutral-900">{currentPage * pageSize + contacts.length}</span>
|
||||
@@ -455,7 +537,12 @@ export default function ContactsPage() {
|
||||
open={showBulkActionsDialog}
|
||||
onOpenChange={setShowBulkActionsDialog}
|
||||
operation={bulkOperation}
|
||||
contactIds={Array.from(selectedContacts)}
|
||||
selector={
|
||||
selectAllMatching
|
||||
? {mode: 'query', filter: search ? {search} : {}, excludeIds: Array.from(excludedContacts)}
|
||||
: {mode: 'ids', contactIds: Array.from(selectedContacts)}
|
||||
}
|
||||
targetCount={effectiveSelectionCount}
|
||||
onSuccess={() => {
|
||||
mutate();
|
||||
clearSelection();
|
||||
@@ -885,23 +972,32 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
|
||||
);
|
||||
}
|
||||
|
||||
type BulkSelector =
|
||||
| {mode: 'ids'; contactIds: string[]}
|
||||
| {mode: 'query'; filter: {search?: string}; excludeIds: string[]};
|
||||
|
||||
interface BulkActionsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete' | null;
|
||||
contactIds: string[];
|
||||
selector: BulkSelector;
|
||||
targetCount: number;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface BulkActionResult {
|
||||
operation: string;
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
totalRequested: number;
|
||||
/** Contacts whose state was actually changed by this run. */
|
||||
successCount: number;
|
||||
/** Subscribe/unsubscribe only: contacts that were already in the target state. */
|
||||
unchangedCount: number;
|
||||
/** Contacts that errored or weren't found. */
|
||||
failureCount: number;
|
||||
errors: Array<{contactId: string; email: string; error: string}>;
|
||||
}
|
||||
|
||||
function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
|
||||
function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount, onSuccess}: BulkActionsDialogProps) {
|
||||
const [, setJobId] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
@@ -949,8 +1045,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
||||
}
|
||||
|
||||
if (response.result) {
|
||||
const {successCount, failureCount} = response.result;
|
||||
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
|
||||
toast.success(buildToastSummary(response.result));
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
@@ -988,7 +1083,7 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
||||
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
|
||||
'POST',
|
||||
endpoint,
|
||||
{contactIds},
|
||||
selector,
|
||||
);
|
||||
|
||||
setJobId(data.jobId);
|
||||
@@ -1019,103 +1114,97 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const getOperationLabel = () => {
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
return 'Subscribe';
|
||||
case 'unsubscribe':
|
||||
return 'Unsubscribe';
|
||||
case 'delete':
|
||||
return 'Delete';
|
||||
default:
|
||||
return 'Process';
|
||||
}
|
||||
};
|
||||
const copy = getOperationCopy(operation);
|
||||
|
||||
const getOperationColor = () => {
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
return 'green';
|
||||
case 'unsubscribe':
|
||||
return 'yellow';
|
||||
case 'delete':
|
||||
return 'red';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
const isQueueing = status === 'processing' && progress === 0;
|
||||
const dialogTitle =
|
||||
status === 'completed'
|
||||
? copy.completedTitle
|
||||
: status === 'processing'
|
||||
? copy.progressTitle
|
||||
: status === 'failed'
|
||||
? copy.failedTitle
|
||||
: copy.title;
|
||||
|
||||
const handleRetry = () => {
|
||||
setErrorMessage(null);
|
||||
setStatus('idle');
|
||||
void handleConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
|
||||
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{status === 'idle' && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-neutral-700">
|
||||
{operation === 'delete' ? 'Permanently delete' : operation === 'subscribe' ? 'Subscribe' : 'Unsubscribe'}{' '}
|
||||
<span className="font-medium text-neutral-900">{contactIds.length} contact{contactIds.length !== 1 ? 's' : ''}</span>?
|
||||
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||
<p className="text-sm text-neutral-700 leading-relaxed">
|
||||
{copy.confirmVerb}{' '}
|
||||
<span className="font-medium text-neutral-900 tabular-nums">
|
||||
{targetCount.toLocaleString()} contact{targetCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
?
|
||||
{copy.skipNote && <span className="text-neutral-500"> {copy.skipNote}</span>}
|
||||
</p>
|
||||
{operation === 'delete' && (
|
||||
<p className="text-xs text-red-500">This action cannot be undone.</p>
|
||||
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-xs text-red-700">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" strokeWidth={2.25} />
|
||||
<p className="leading-relaxed">
|
||||
<span className="font-medium">This action cannot be undone.</span> Contacts and their event history will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selector.mode === 'query' && (
|
||||
<p className="text-xs text-neutral-500 leading-relaxed">
|
||||
Contacts are evaluated when the job runs — any added in the meantime may also be included.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'processing' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-600">Processing contacts...</span>
|
||||
<span className="text-neutral-900 font-medium">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{width: `${progress}%`}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'completed' && result && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-neutral-600">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 flex-shrink-0" />
|
||||
<span>
|
||||
<span className="font-medium text-neutral-900">{result.successCount}</span> succeeded
|
||||
{result.failureCount > 0 && (
|
||||
<>, <span className="text-red-600">{result.failureCount}</span> failed</>
|
||||
)}
|
||||
<div className="space-y-3 py-1 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||
<div className="flex items-baseline justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-neutral-600">
|
||||
{isQueueing && <Loader2 className="h-3.5 w-3.5 animate-spin text-neutral-400" />}
|
||||
<span>
|
||||
{isQueueing
|
||||
? 'Queued — starting up…'
|
||||
: `${copy.processingLabel} ${targetCount.toLocaleString()} contact${targetCount !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`tabular-nums font-medium transition-opacity ${
|
||||
isQueueing ? 'text-neutral-400' : 'text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{progress}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
|
||||
<div className="text-xs text-neutral-600">
|
||||
{result.errors.slice(0, 10).map((error, idx) => (
|
||||
<div key={idx} className="px-3 py-2 border-b border-neutral-100 last:border-0 text-red-600">
|
||||
{error.error}
|
||||
</div>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<div className="px-3 py-2 text-neutral-500">
|
||||
+{result.errors.length - 10} more errors
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full bg-neutral-100 rounded-full h-1.5 overflow-hidden">
|
||||
{isQueueing ? (
|
||||
<div className="absolute inset-y-0 left-0 w-1/3 rounded-full bg-neutral-300 motion-safe:animate-[indeterminate_1.4s_ease-in-out_infinite]" />
|
||||
) : (
|
||||
<div
|
||||
className="bg-neutral-900 h-full rounded-full transition-[width] duration-500 ease-out"
|
||||
style={{width: `${progress}%`}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'completed' && result && <BulkResultSummary result={result} />}
|
||||
|
||||
{status === 'failed' && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<XCircle className="h-4 w-4 text-red-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-red-600">{errorMessage || 'Please try again.'}</p>
|
||||
<div className="flex items-start gap-2.5 rounded-md border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" strokeWidth={2.25} />
|
||||
<p className="leading-relaxed">{errorMessage || 'Something went wrong. Please try again.'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1132,16 +1221,25 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
||||
disabled={isProcessing}
|
||||
variant={operation === 'delete' ? 'destructive' : 'default'}
|
||||
>
|
||||
{isProcessing ? 'Starting...' : getOperationLabel()}
|
||||
{isProcessing ? 'Starting…' : copy.confirmButton}
|
||||
</Button>
|
||||
</>
|
||||
) : status === 'failed' ? (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button type="button" onClick={handleRetry} variant={operation === 'delete' ? 'destructive' : 'default'}>
|
||||
Try again
|
||||
</Button>
|
||||
</>
|
||||
) : status === 'completed' ? (
|
||||
<Button type="button" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
variant={status === 'completed' ? 'default' : 'outline'}
|
||||
>
|
||||
{status === 'completed' ? 'Done' : 'Hide'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -1152,11 +1250,218 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
|
||||
open={showCloseConfirmDialog}
|
||||
onOpenChange={setShowCloseConfirmDialog}
|
||||
onConfirm={confirmClose}
|
||||
title="Close Operation"
|
||||
description="Operation is still in progress. Are you sure you want to close?"
|
||||
confirmText="Close Anyway"
|
||||
variant="destructive"
|
||||
title="Hide this dialog?"
|
||||
description="The job will keep running in the background. You won't see the result here, but the contacts will still be updated."
|
||||
confirmText="Hide"
|
||||
variant="default"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface OperationCopy {
|
||||
title: string;
|
||||
progressTitle: string;
|
||||
completedTitle: string;
|
||||
failedTitle: string;
|
||||
confirmVerb: string;
|
||||
confirmButton: string;
|
||||
processingLabel: string;
|
||||
/** Past-tense verb used in result rows: "12 subscribed". */
|
||||
changedVerb: string;
|
||||
/** Result-state noun phrase: "contacts subscribed" — pluralisation handled separately. */
|
||||
summaryNoun: string;
|
||||
/** Past participle for "already X": "already subscribed". null = no skip case. */
|
||||
alreadyState: string | null;
|
||||
/** Note shown next to the confirm prompt for ops with skip semantics. */
|
||||
skipNote: string | null;
|
||||
}
|
||||
|
||||
function getOperationCopy(operation: 'subscribe' | 'unsubscribe' | 'delete' | null): OperationCopy {
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
return {
|
||||
title: 'Subscribe contacts',
|
||||
progressTitle: 'Subscribing…',
|
||||
completedTitle: 'Subscribed',
|
||||
failedTitle: "Couldn't subscribe contacts",
|
||||
confirmVerb: 'Subscribe',
|
||||
confirmButton: 'Subscribe',
|
||||
processingLabel: 'Subscribing',
|
||||
changedVerb: 'subscribed',
|
||||
summaryNoun: 'subscribed',
|
||||
alreadyState: 'already subscribed',
|
||||
skipNote: 'Already-subscribed contacts will be skipped.',
|
||||
};
|
||||
case 'unsubscribe':
|
||||
return {
|
||||
title: 'Unsubscribe contacts',
|
||||
progressTitle: 'Unsubscribing…',
|
||||
completedTitle: 'Unsubscribed',
|
||||
failedTitle: "Couldn't unsubscribe contacts",
|
||||
confirmVerb: 'Unsubscribe',
|
||||
confirmButton: 'Unsubscribe',
|
||||
processingLabel: 'Unsubscribing',
|
||||
changedVerb: 'unsubscribed',
|
||||
summaryNoun: 'unsubscribed',
|
||||
alreadyState: 'already unsubscribed',
|
||||
skipNote: 'Already-unsubscribed contacts will be skipped.',
|
||||
};
|
||||
case 'delete':
|
||||
return {
|
||||
title: 'Delete contacts',
|
||||
progressTitle: 'Deleting…',
|
||||
completedTitle: 'Deleted',
|
||||
failedTitle: "Couldn't delete contacts",
|
||||
confirmVerb: 'Permanently delete',
|
||||
confirmButton: 'Delete',
|
||||
processingLabel: 'Deleting',
|
||||
changedVerb: 'deleted',
|
||||
summaryNoun: 'removed',
|
||||
alreadyState: null,
|
||||
skipNote: null,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Process contacts',
|
||||
progressTitle: 'Processing…',
|
||||
completedTitle: 'Done',
|
||||
failedTitle: 'Operation failed',
|
||||
confirmVerb: 'Process',
|
||||
confirmButton: 'Process',
|
||||
processingLabel: 'Processing',
|
||||
changedVerb: 'processed',
|
||||
summaryNoun: 'processed',
|
||||
alreadyState: null,
|
||||
skipNote: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildToastSummary(result: BulkActionResult): string {
|
||||
const copy = getOperationCopy(result.operation);
|
||||
const parts: string[] = [];
|
||||
if (result.successCount > 0) parts.push(`${result.successCount.toLocaleString()} ${copy.changedVerb}`);
|
||||
if (result.unchangedCount > 0 && copy.alreadyState) {
|
||||
parts.push(`${result.unchangedCount.toLocaleString()} ${copy.alreadyState}`);
|
||||
}
|
||||
if (result.failureCount > 0) parts.push(`${result.failureCount.toLocaleString()} failed`);
|
||||
if (parts.length === 0) return 'No contacts to update';
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
function BulkResultSummary({result}: {result: BulkActionResult}) {
|
||||
const copy = getOperationCopy(result.operation);
|
||||
const {successCount, unchangedCount, failureCount} = result;
|
||||
const noChanges = successCount === 0 && failureCount === 0 && unchangedCount > 0;
|
||||
const total = successCount + unchangedCount + failureCount;
|
||||
|
||||
// Build the row list. The "primary" row is the row that represents what the
|
||||
// user actually got — usually the changed count, but when nothing changed we
|
||||
// promote the "already in state" row so the summary still has a clear lead.
|
||||
type Row = {
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
primary?: boolean;
|
||||
tone?: 'default' | 'danger';
|
||||
};
|
||||
const rows: Row[] = [];
|
||||
|
||||
if (noChanges && copy.alreadyState) {
|
||||
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount, primary: true});
|
||||
} else {
|
||||
rows.push({key: 'changed', label: copy.completedTitle, count: successCount, primary: true});
|
||||
if (unchangedCount > 0 && copy.alreadyState) {
|
||||
rows.push({key: 'already', label: copy.alreadyState, count: unchangedCount});
|
||||
}
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
rows.push({key: 'failed', label: 'Failed', count: failureCount, tone: 'danger'});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 motion-safe:animate-in motion-safe:fade-in-50 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300">
|
||||
<div className="rounded-lg border border-neutral-200 overflow-hidden divide-y divide-neutral-100">
|
||||
{rows.map(row => {
|
||||
const isPrimary = !!row.primary;
|
||||
const isDanger = row.tone === 'danger';
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className={`flex items-center gap-3 px-4 ${isPrimary ? 'py-4' : 'py-2.5'}`}
|
||||
>
|
||||
{/* Status mark — only on the primary row. Subsequent rows leave the
|
||||
same column blank to keep the labels in a single visual track. */}
|
||||
<div className="w-7 shrink-0 flex items-center">
|
||||
{isPrimary && (
|
||||
<div
|
||||
className={`flex h-7 w-7 items-center justify-center rounded-full ${
|
||||
noChanges ? 'bg-neutral-100 text-neutral-500' : 'bg-neutral-900 text-white'
|
||||
}`}
|
||||
>
|
||||
{noChanges ? (
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 first-letter:capitalize ${
|
||||
isPrimary
|
||||
? 'text-sm font-medium text-neutral-900'
|
||||
: isDanger
|
||||
? 'text-sm text-red-600'
|
||||
: 'text-sm text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{row.label}
|
||||
</div>
|
||||
<div
|
||||
className={`tabular-nums tracking-tight ${
|
||||
isPrimary
|
||||
? 'text-2xl font-semibold text-neutral-900 leading-none'
|
||||
: isDanger
|
||||
? 'text-sm font-medium text-red-700'
|
||||
: 'text-sm font-medium text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{row.count.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{total > 1 && rows.length > 1 && (
|
||||
<div className="px-4 flex items-baseline justify-between text-xs text-neutral-500">
|
||||
<span>Total processed</span>
|
||||
<span className="tabular-nums font-medium text-neutral-700">{total.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-xs text-neutral-500 hover:text-neutral-700 select-none px-4">
|
||||
Show error details ({result.errors.length.toLocaleString()})
|
||||
</summary>
|
||||
<div className="mt-2 max-h-40 overflow-y-auto rounded-md border border-neutral-200 divide-y divide-neutral-100 text-xs">
|
||||
{result.errors.slice(0, 10).map((error, idx) => (
|
||||
<div key={idx} className="px-3 py-2 text-red-700">
|
||||
{error.error}
|
||||
</div>
|
||||
))}
|
||||
{result.errors.length > 10 && (
|
||||
<div className="px-3 py-2 text-neutral-500">
|
||||
+{(result.errors.length - 10).toLocaleString()} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+504
-54
@@ -10,10 +10,30 @@ import {
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
|
||||
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||
import {animate, AnimatePresence, motion, useMotionValue, useTransform} from 'framer-motion';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowDownRight,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
Eye,
|
||||
Inbox,
|
||||
Mail,
|
||||
Minus,
|
||||
MousePointerClick,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
|
||||
import {DashboardLayout} from '../components/DashboardLayout';
|
||||
import {QuickStart} from '../components/QuickStart';
|
||||
@@ -28,6 +48,195 @@ import {useConfig} from '../lib/hooks/useConfig';
|
||||
import {useUser} from '../lib/hooks/useUser';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
function getGreeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 23 || hour < 5) return 'Working late';
|
||||
if (hour < 12) return 'Good morning';
|
||||
if (hour < 18) return 'Good afternoon';
|
||||
return 'Good evening';
|
||||
}
|
||||
|
||||
function relativeTime(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return date.toLocaleDateString(undefined, {month: 'short', day: 'numeric'});
|
||||
}
|
||||
|
||||
type TrendDirection = 'up' | 'down' | 'flat' | 'new' | 'none';
|
||||
|
||||
interface TrendInfo {
|
||||
direction: TrendDirection;
|
||||
pct: number;
|
||||
}
|
||||
|
||||
function computeTrend(current: number, previous: number): TrendInfo {
|
||||
if (previous === 0 && current === 0) return {direction: 'none', pct: 0};
|
||||
if (previous === 0 && current > 0) return {direction: 'new', pct: 0};
|
||||
const pct = ((current - previous) / Math.abs(previous)) * 100;
|
||||
if (Math.abs(pct) < 0.5) return {direction: 'flat', pct: 0};
|
||||
return {direction: pct > 0 ? 'up' : 'down', pct: Math.abs(pct)};
|
||||
}
|
||||
|
||||
function TrendChip({trend, label}: {trend: TrendInfo; label?: string}) {
|
||||
if (trend.direction === 'none') {
|
||||
return (
|
||||
<p className="mt-1 text-xs text-neutral-400 tabular-nums">{label ?? 'No data yet'}</p>
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
up: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
|
||||
down: {Icon: ArrowDownRight, color: 'text-red-700', bg: 'bg-red-50'},
|
||||
flat: {Icon: Minus, color: 'text-neutral-600', bg: 'bg-neutral-100'},
|
||||
new: {Icon: ArrowUpRight, color: 'text-emerald-700', bg: 'bg-emerald-50'},
|
||||
}[trend.direction];
|
||||
|
||||
const {Icon, color, bg} = config;
|
||||
const text =
|
||||
trend.direction === 'new'
|
||||
? 'New'
|
||||
: trend.direction === 'flat'
|
||||
? 'No change'
|
||||
: `${trend.pct.toFixed(trend.pct >= 100 ? 0 : 1)}%`;
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium tabular-nums ${bg} ${color}`}>
|
||||
<Icon className="h-3 w-3" strokeWidth={2.5} />
|
||||
{text}
|
||||
</span>
|
||||
<span className="text-neutral-400">vs previous 30d</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedNumber({value, format}: {value: number; format?: (n: number) => string}) {
|
||||
const motionValue = useMotionValue(0);
|
||||
const rounded = useTransform(motionValue, latest =>
|
||||
format ? format(Math.round(latest)) : Math.round(latest).toLocaleString(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controls = animate(motionValue, value, {
|
||||
duration: 1.1,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
});
|
||||
return () => controls.stop();
|
||||
}, [value, motionValue]);
|
||||
|
||||
return <motion.span>{rounded}</motion.span>;
|
||||
}
|
||||
|
||||
interface ActivityVisual {
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
tone: 'neutral' | 'green' | 'blue' | 'amber' | 'red';
|
||||
label: string;
|
||||
}
|
||||
|
||||
function activityVisual(a: Activity): ActivityVisual {
|
||||
switch (a.type) {
|
||||
case 'email.sent':
|
||||
return {icon: Send, tone: 'neutral', label: 'Sent'};
|
||||
case 'email.delivered':
|
||||
return {icon: Inbox, tone: 'green', label: 'Delivered'};
|
||||
case 'email.opened':
|
||||
return {icon: Eye, tone: 'green', label: 'Opened'};
|
||||
case 'email.clicked':
|
||||
return {icon: MousePointerClick, tone: 'blue', label: 'Clicked'};
|
||||
case 'email.bounced':
|
||||
return {icon: XCircle, tone: 'red', label: 'Bounced'};
|
||||
case 'email.complaint':
|
||||
return {icon: AlertCircle, tone: 'red', label: 'Complaint'};
|
||||
case 'event.triggered':
|
||||
return {icon: Zap, tone: 'amber', label: 'Event'};
|
||||
case 'campaign.sent':
|
||||
return {icon: Mail, tone: 'neutral', label: 'Campaign'};
|
||||
case 'campaign.scheduled':
|
||||
return {icon: Calendar, tone: 'blue', label: 'Scheduled'};
|
||||
case 'workflow.started':
|
||||
case 'workflow.completed':
|
||||
case 'workflow.email.scheduled':
|
||||
return {icon: Workflow, tone: 'amber', label: 'Workflow'};
|
||||
default:
|
||||
return {icon: Zap, tone: 'neutral', label: 'Event'};
|
||||
}
|
||||
}
|
||||
|
||||
const TONE_CLASSES: Record<ActivityVisual['tone'], {bg: string; fg: string}> = {
|
||||
neutral: {bg: 'bg-neutral-100', fg: 'text-neutral-700'},
|
||||
green: {bg: 'bg-emerald-50', fg: 'text-emerald-700'},
|
||||
blue: {bg: 'bg-sky-50', fg: 'text-sky-700'},
|
||||
amber: {bg: 'bg-amber-50', fg: 'text-amber-700'},
|
||||
red: {bg: 'bg-red-50', fg: 'text-red-700'},
|
||||
};
|
||||
|
||||
function activityTitle(a: Activity): string {
|
||||
const m = a.metadata;
|
||||
if (typeof m.subject === 'string' && m.subject) return m.subject;
|
||||
if (typeof m.eventName === 'string' && m.eventName) return m.eventName;
|
||||
if (typeof m.campaignName === 'string' && m.campaignName) return m.campaignName;
|
||||
if (typeof m.workflowName === 'string' && m.workflowName) return m.workflowName;
|
||||
return activityVisual(a).label;
|
||||
}
|
||||
|
||||
function LivePulse({count}: {count: number}) {
|
||||
const isLive = count > 0;
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700">
|
||||
<span className="relative flex h-2 w-2">
|
||||
{isLive && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||
)}
|
||||
<span
|
||||
className={`relative inline-flex h-2 w-2 rounded-full ${isLive ? 'bg-emerald-500' : 'bg-neutral-300'}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{isLive ? `${count.toLocaleString()} ${count === 1 ? 'event' : 'events'} in the last 5 min` : 'Quiet right now'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactActivityRow({activity}: {activity: Activity}) {
|
||||
const visual = activityVisual(activity);
|
||||
const Icon = visual.icon;
|
||||
const tone = TONE_CLASSES[visual.tone];
|
||||
const title = activityTitle(activity);
|
||||
const subtitle = activity.contactEmail;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{opacity: 0, y: -8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={{opacity: 0, y: 8}}
|
||||
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-neutral-50"
|
||||
>
|
||||
<div className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md ${tone.bg}`}>
|
||||
<Icon className={`h-4 w-4 ${tone.fg}`} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<p className="truncate text-sm font-medium text-neutral-900">{title}</p>
|
||||
<span className="flex-shrink-0 text-[11px] text-neutral-400">{visual.label}</span>
|
||||
</div>
|
||||
{subtitle && <p className="truncate text-xs text-neutral-500">{subtitle}</p>}
|
||||
</div>
|
||||
<span className="flex-shrink-0 tabular-nums text-xs text-neutral-400">
|
||||
{relativeTime(new Date(activity.timestamp))}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const {activeProject} = useActiveProject();
|
||||
const {totalContacts, totalEmailsSent, totalCampaigns, openRate, isLoading} = useDashboardStats();
|
||||
@@ -41,29 +250,129 @@ export default function Index() {
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [resendMessage, setResendMessage] = useState<string>('');
|
||||
|
||||
// Previous-period stats (60d ago to 30d ago) for trend comparison.
|
||||
// Round to UTC day boundary so the URL — and therefore the Redis cache key —
|
||||
// is identical for every user on the same UTC day, letting the 5-minute
|
||||
// server-side stats cache actually be shared across the user base.
|
||||
const previousRangeUrl = useMemo(() => {
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setUTCDate(today.getUTCDate() - 30);
|
||||
const sixtyDaysAgo = new Date(today);
|
||||
sixtyDaysAgo.setUTCDate(today.getUTCDate() - 60);
|
||||
return `/activity/stats?startDate=${encodeURIComponent(sixtyDaysAgo.toISOString())}&endDate=${encodeURIComponent(thirtyDaysAgo.toISOString())}`;
|
||||
}, []);
|
||||
const {data: previousStats} = useSWR<ActivityStats>(previousRangeUrl, {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const emailsTrend = useMemo(
|
||||
() => computeTrend(totalEmailsSent, previousStats?.totalEmailsSent ?? 0),
|
||||
[totalEmailsSent, previousStats?.totalEmailsSent],
|
||||
);
|
||||
const openRateTrend = useMemo(
|
||||
() => computeTrend(openRate, previousStats?.openRate ?? 0),
|
||||
[openRate, previousStats?.openRate],
|
||||
);
|
||||
|
||||
// Live pulse — refresh every 30s. This is the actual real-time signal, so it
|
||||
// gets the tightest cadence. Server-side it is backed by a short Redis cache
|
||||
// (see Activity controller) so the polling load stays bounded.
|
||||
const {data: recentCount} = useSWR<{count: number; minutes: number}>('/activity/recent-count?minutes=5', {
|
||||
refreshInterval: 30_000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 15_000,
|
||||
});
|
||||
|
||||
// Live activity feed — last 10 events, refresh every 60s. Slower than the
|
||||
// pulse because the heavier query doesn't need to be tracked second-by-second.
|
||||
// Sized to roughly match the Quick Start card's height in the side-by-side layout.
|
||||
const {data: recentActivity} = useSWR<CursorPaginatedResponse<Activity>>('/activity?limit=10', {
|
||||
refreshInterval: 60_000,
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 30_000,
|
||||
});
|
||||
|
||||
const greeting = useMemo(() => getGreeting(), []);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
if (isLoading) return 'Catching up on the last 30 days.';
|
||||
if (totalEmailsSent === 0) {
|
||||
if (totalContacts === 0) return `${activeProject?.name ?? 'Your project'} is fresh. Time to send the first email.`;
|
||||
return `${totalContacts.toLocaleString()} ${totalContacts === 1 ? 'contact' : 'contacts'} ready. Time to send something.`;
|
||||
}
|
||||
const projectLabel = activeProject?.name ? `${activeProject.name} sent` : 'You sent';
|
||||
const base = `${projectLabel} ${totalEmailsSent.toLocaleString()} ${totalEmailsSent === 1 ? 'email' : 'emails'} in the last 30 days.`;
|
||||
if (openRate >= 40) return `${base} Open rate is well above average.`;
|
||||
if (openRate >= 25) return `${base} Open rate is healthy.`;
|
||||
return base;
|
||||
}, [isLoading, totalEmailsSent, totalContacts, openRate, activeProject?.name]);
|
||||
|
||||
// Friendly console message for the developer audience. Once per session.
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as unknown as {__plunkHi?: boolean};
|
||||
if (w.__plunkHi) return;
|
||||
w.__plunkHi = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'%cPlunk%c Built for developers who care about email.\nFound a rough edge? support@useplunk.com',
|
||||
'font: 600 14px ui-sans-serif, system-ui; color: #0a0a0a; background: #f5f5f5; padding: 2px 8px; border-radius: 4px;',
|
||||
'color: #525252; font: 12px ui-sans-serif, system-ui;',
|
||||
);
|
||||
}, []);
|
||||
|
||||
// totalCampaigns intentionally unused — replaced by Deliverability card below
|
||||
void totalCampaigns;
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: 'Total Contacts',
|
||||
value: totalContacts.toLocaleString(),
|
||||
value: totalContacts,
|
||||
icon: Users,
|
||||
format: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'Emails Sent',
|
||||
value: totalEmailsSent.toLocaleString(),
|
||||
value: totalEmailsSent,
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
name: 'Campaigns',
|
||||
value: totalCampaigns.toLocaleString(),
|
||||
icon: Send,
|
||||
format: (n: number) => n.toLocaleString(),
|
||||
},
|
||||
{
|
||||
name: 'Open Rate',
|
||||
value: `${openRate.toFixed(1)}%`,
|
||||
value: openRate,
|
||||
icon: TrendingUp,
|
||||
format: (n: number) => `${n.toFixed(1)}%`,
|
||||
},
|
||||
];
|
||||
|
||||
// Deliverability — prefer 7-day window, fall back to all-time when no 7-day sends
|
||||
const sevenDay = securityMetrics?.status.sevenDay;
|
||||
const allTime = securityMetrics?.status.allTime;
|
||||
const delivWindow = sevenDay && sevenDay.total > 0 ? sevenDay : allTime;
|
||||
const delivWindowLabel = sevenDay && sevenDay.total > 0 ? 'Last 7 days' : 'All time';
|
||||
const deliveryRate =
|
||||
delivWindow && delivWindow.total > 0 ? ((delivWindow.total - delivWindow.bounces) / delivWindow.total) * 100 : 0;
|
||||
const bounceRate = delivWindow?.bounceRate ?? 0;
|
||||
const complaintRate = delivWindow?.complaintRate ?? 0;
|
||||
const hasDelivData = !!delivWindow && delivWindow.total > 0;
|
||||
|
||||
const bounceLevel = securityMetrics?.levels.bounce7Day ?? 'healthy';
|
||||
const complaintLevel = securityMetrics?.levels.complaint7Day ?? 'healthy';
|
||||
const worstLevel: 'healthy' | 'warning' | 'critical' =
|
||||
bounceLevel === 'critical' || complaintLevel === 'critical'
|
||||
? 'critical'
|
||||
: bounceLevel === 'warning' || complaintLevel === 'warning'
|
||||
? 'warning'
|
||||
: 'healthy';
|
||||
const healthLabel = !hasDelivData ? 'No data yet' : worstLevel === 'healthy' ? 'Healthy' : worstLevel === 'warning' ? 'Watch' : 'Critical';
|
||||
const healthDot =
|
||||
!hasDelivData ? 'bg-neutral-300' : worstLevel === 'healthy' ? 'bg-emerald-500' : worstLevel === 'warning' ? 'bg-amber-500' : 'bg-red-500';
|
||||
const healthText =
|
||||
!hasDelivData ? 'text-neutral-500' : worstLevel === 'healthy' ? 'text-emerald-700' : worstLevel === 'warning' ? 'text-amber-700' : 'text-red-700';
|
||||
|
||||
async function handleResendVerification() {
|
||||
setIsResending(true);
|
||||
setResendMessage('');
|
||||
@@ -82,6 +391,9 @@ export default function Index() {
|
||||
}
|
||||
}
|
||||
|
||||
const recentItems = recentActivity?.data ?? [];
|
||||
const liveCount = recentCount?.count ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo title="Dashboard" />
|
||||
@@ -162,64 +474,202 @@ export default function Index() {
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 tracking-tight">{greeting}</h1>
|
||||
<p className="mt-1.5 text-sm text-neutral-500">{subtitle}</p>
|
||||
</div>
|
||||
<LivePulse count={liveCount} />
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{stats.map(stat => {
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isEmails = stat.name === 'Emails Sent';
|
||||
const isOpenRate = stat.name === 'Open Rate';
|
||||
return (
|
||||
<Card key={stat.name}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<Icon className="h-4 w-4 text-neutral-500" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<motion.div
|
||||
key={stat.name}
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 0.05 + index * 0.06,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
whileHover={{y: -2}}
|
||||
className="group"
|
||||
>
|
||||
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>{stat.name}</CardDescription>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-neutral-50 border border-neutral-200/60 transition-colors duration-200 group-hover:bg-neutral-900 group-hover:border-neutral-900">
|
||||
<Icon className="h-3.5 w-3.5 text-neutral-500 transition-colors duration-200 group-hover:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-16" />
|
||||
) : (
|
||||
<AnimatedNumber value={stat.value} format={stat.format} />
|
||||
)}
|
||||
</CardTitle>
|
||||
{isEmails && !isLoading && previousStats && <TrendChip trend={emailsTrend} />}
|
||||
{isOpenRate && !isLoading && previousStats && <TrendChip trend={openRateTrend} />}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Deliverability health */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 12}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, delay: 0.05 + stats.length * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
whileHover={{y: -2}}
|
||||
className="group"
|
||||
>
|
||||
<Card className="relative overflow-hidden h-full transition-colors duration-200 hover:border-neutral-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardDescription>Deliverability</CardDescription>
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full bg-neutral-50 border border-neutral-200/60 px-2 py-0.5">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
{hasDelivData && worstLevel === 'healthy' && (
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-70" />
|
||||
)}
|
||||
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${healthDot}`} />
|
||||
</span>
|
||||
<span className={`text-[11px] font-medium ${healthText}`}>{healthLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl tabular-nums">
|
||||
{!securityMetrics ? (
|
||||
<Skeleton className="h-7 w-20" />
|
||||
) : hasDelivData ? (
|
||||
<AnimatedNumber value={deliveryRate} format={n => `${n.toFixed(1)}%`} />
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{hasDelivData ? 'delivered' : 'No emails sent yet'}
|
||||
{hasDelivData && <span className="text-neutral-400"> · {delivWindowLabel}</span>}
|
||||
</p>
|
||||
</CardHeader>
|
||||
{hasDelivData && (
|
||||
<div className="px-6 pb-4 -mt-1">
|
||||
<div className="flex items-center gap-4 text-[11px] text-neutral-500 tabular-nums">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-3 w-3 text-neutral-400" />
|
||||
Bounce <span className="font-medium text-neutral-700">{bounceRate.toFixed(2)}%</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3 text-neutral-400" />
|
||||
Complaint <span className="font-medium text-neutral-700">{complaintRate.toFixed(3)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & API Keys */}
|
||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
|
||||
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
|
||||
{/* Quick Start + Recent Activity — 50/50 working area with a fixed
|
||||
row height so the layout doesn't reflow as Quick Start steps are
|
||||
completed. Both cards scroll internally. */}
|
||||
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2 lg:h-[480px]'}`}>
|
||||
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
|
||||
|
||||
{/* API Keys */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activeProject ? (
|
||||
<>
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</>
|
||||
<div className={!bannerActive ? 'lg:relative' : ''}>
|
||||
<Card
|
||||
className={`flex flex-col h-full ${
|
||||
!bannerActive ? 'lg:absolute lg:inset-0' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Recent activity</CardTitle>
|
||||
<CardDescription>Live feed of what’s happening across your project</CardDescription>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/activity">View all</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 overflow-y-auto">
|
||||
{!recentActivity ? (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 px-2 py-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-2/5" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentItems.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-neutral-100">
|
||||
<Inbox className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-neutral-700">Nothing has happened yet</p>
|
||||
<p className="text-xs text-neutral-500 max-w-xs">
|
||||
Send your first email or trigger an event and you’ll see it land here in real time.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
<div className="space-y-0.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{recentItems.map(activity => (
|
||||
<CompactActivityRow key={activity.id} activity={activity} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys — full-width slim band with the two keys side-by-side */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
<CardDescription>Use these keys to integrate with Plunk</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activeProject ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6">
|
||||
<ApiKeyDisplay
|
||||
label="Public Key"
|
||||
value={activeProject.public}
|
||||
description="Use this key for client-side integrations"
|
||||
/>
|
||||
<ApiKeyDisplay
|
||||
label="Secret Key"
|
||||
value={activeProject.secret}
|
||||
description="Keep this key secure and never expose it publicly"
|
||||
isSecret
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500">No project selected</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
|
||||
@@ -121,57 +121,35 @@ export default function TemplateEditorPage() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<NextSeo title={template.name} />
|
||||
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
<div className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
|
||||
<p className="text-neutral-500 mt-1 text-sm sm:text-base">Make changes to your email template</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex-1">
|
||||
{!hasChanges && !isSubmitting && (
|
||||
<span className="text-xs sm:text-sm text-neutral-500">All changes saved</span>
|
||||
)}
|
||||
{hasChanges && !isSubmitting && (
|
||||
<span className="text-xs sm:text-sm text-amber-600">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isSubmitting} className="flex-1 sm:flex-none">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{isSubmitting ? 'Saving...' : 'Save Changes'}</span>
|
||||
<span className="sm:hidden">{isSubmitting ? 'Saving...' : 'Save'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
|
||||
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: hasChanges
|
||||
? <span className="text-amber-600">Unsaved changes</span>
|
||||
: 'All changes saved'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Editor */}
|
||||
<div className="space-y-6">
|
||||
{/* Template Settings */}
|
||||
<Card>
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
{/* Row 1: Basic Info + Template Type */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template Settings</CardTitle>
|
||||
<CardDescription>Configure the basic settings for your template</CardDescription>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>Name and describe your template</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Template Name *</Label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
@@ -182,7 +160,7 @@ export default function TemplateEditorPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
@@ -192,94 +170,124 @@ export default function TemplateEditorPage() {
|
||||
placeholder="Sent to new subscribers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Type *</Label>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
{([
|
||||
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'} ,
|
||||
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||
] as const).map(({value, label, description}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setEditedTemplate({...editedTemplate, type: value})}
|
||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||
editedTemplate.type === value
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
|
||||
<div className="mt-2 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{unsubscribeUrl}}'}
|
||||
</code>
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{manageUrl}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="subject">Subject Line *</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={editedTemplate.subject || ''}
|
||||
onChange={e => setEditedTemplate({...editedTemplate, subject: e.target.value})}
|
||||
required
|
||||
placeholder="Welcome to our platform!"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">Use {'{{variableName}}'} for dynamic content</p>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={editedTemplate.from || ''}
|
||||
fromName={editedTemplate.fromName || ''}
|
||||
replyTo={editedTemplate.replyTo || ''}
|
||||
onFromChange={value => setEditedTemplate({...editedTemplate, from: value})}
|
||||
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
|
||||
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
layout="vertical"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Template Type</CardTitle>
|
||||
<CardDescription>Choose how this template should be treated</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
{([
|
||||
{value: 'MARKETING', label: 'Marketing', description: 'Subscribed contacts, includes unsubscribe link'},
|
||||
{value: 'TRANSACTIONAL', label: 'Transactional', description: 'All contacts, no subscription check or footer'},
|
||||
{value: 'HEADLESS', label: 'Headless', description: 'Subscribed contacts, no Plunk footer'},
|
||||
] as const).map(({value, label, description}) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setEditedTemplate({...editedTemplate, type: value})}
|
||||
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
|
||||
editedTemplate.type === value
|
||||
? 'border-neutral-900 bg-neutral-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-sm text-neutral-900 shrink-0">{label}</span>
|
||||
<span className="text-xs text-neutral-500 ml-4 text-right">{description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{editedTemplate.type === 'HEADLESS' && !detectUnsubscribeSignal(editedTemplate.body ?? '') && (
|
||||
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-amber-200 bg-amber-100/60 px-3 py-2">
|
||||
<TriangleAlert className="h-3.5 w-3.5 text-amber-600 shrink-0" />
|
||||
<p className="text-xs font-semibold text-amber-900">No unsubscribe link detected</p>
|
||||
</div>
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">
|
||||
You are responsible for providing recipients a way to opt out. Use the Plunk variables below to build your own footer.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{unsubscribeUrl}}'}
|
||||
</code>
|
||||
<code className="inline-flex items-center rounded bg-amber-100 border border-amber-200 px-1.5 py-0.5 font-mono text-[11px] text-amber-900">
|
||||
{'{{manageUrl}}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Email Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Settings</CardTitle>
|
||||
<CardDescription>Configure sender information and subject</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">Subject Line <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={editedTemplate.subject || ''}
|
||||
onChange={e => setEditedTemplate({...editedTemplate, subject: e.target.value})}
|
||||
required
|
||||
placeholder="Welcome to our platform!"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">Use {'{{variableName}}'} for dynamic content</p>
|
||||
</div>
|
||||
|
||||
<EmailSettings
|
||||
from={editedTemplate.from || ''}
|
||||
fromName={editedTemplate.fromName || ''}
|
||||
replyTo={editedTemplate.replyTo || ''}
|
||||
onFromChange={value => setEditedTemplate({...editedTemplate, from: value})}
|
||||
onFromNameChange={value => setEditedTemplate({...editedTemplate, fromName: value})}
|
||||
onReplyToChange={value => setEditedTemplate({...editedTemplate, replyTo: value})}
|
||||
fromNamePlaceholder={activeProject?.name || 'Your Company'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Body */}
|
||||
<Card className="overflow-visible">
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={editedTemplate.body || ''}
|
||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</form>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Body</CardTitle>
|
||||
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmailEditor
|
||||
value={editedTemplate.body || ''}
|
||||
onChange={body => setEditedTemplate({...editedTemplate, body})}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Template
|
||||
</Button>
|
||||
<Button type="submit" disabled={!hasChanges || isSubmitting}>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Sticky Save Bar */}
|
||||
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
|
||||
|
||||
@@ -206,11 +206,18 @@ export default function WorkflowEditorPage() {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'UPDATE_CONTACT':
|
||||
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
|
||||
errors.push(`"${step.name}" step is missing contact updates`);
|
||||
case 'UPDATE_CONTACT': {
|
||||
const hasUpdates =
|
||||
config.updates && typeof config.updates === 'object' && Object.keys(config.updates).length > 0;
|
||||
const hasSubscriptionAction =
|
||||
typeof config.subscriptionAction === 'string' &&
|
||||
config.subscriptionAction !== 'none' &&
|
||||
config.subscriptionAction !== '';
|
||||
if (!hasUpdates && !hasSubscriptionAction) {
|
||||
errors.push(`"${step.name}" step is missing contact updates or a subscription action`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui';
|
||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||
import {network} from '../../lib/network';
|
||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
||||
import {Calendar, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Link from 'next/link';
|
||||
import {useEffect, useState} from 'react';
|
||||
@@ -66,6 +66,16 @@ export default function WorkflowsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async (workflowId: string) => {
|
||||
try {
|
||||
await network.fetch('POST', `/workflows/${workflowId}/duplicate`);
|
||||
toast.success('Workflow duplicated successfully');
|
||||
void mutate();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
||||
try {
|
||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
||||
@@ -219,6 +229,14 @@ export default function WorkflowsPage() {
|
||||
<Button asChild variant="ghost" size="sm" title="Edit workflow">
|
||||
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title="Duplicate workflow"
|
||||
onClick={() => handleDuplicate(workflow.id)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -164,4 +164,13 @@
|
||||
@apply bg-background text-neutral-800 overflow-hidden;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes indeterminate {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,13 @@ export default async function Page(props: {params: Promise<{slug?: string[]}>})
|
||||
markdownUrl={`/llms.mdx${page.url}`}
|
||||
githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`}
|
||||
/>
|
||||
<p className="ml-auto hidden text-[11px] text-fd-muted-foreground sm:block">
|
||||
Reading this with electronic eyes? Add{' '}
|
||||
<a href={`${page.url}.md`} className="underline decoration-dotted underline-offset-2 transition hover:text-fd-foreground">
|
||||
<code>.md</code>
|
||||
</a>{' '}
|
||||
for the Markdown cut.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DocsBody>
|
||||
@@ -57,6 +64,11 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]}
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
types: {
|
||||
'text/markdown': `${page.url}.md`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
images: [{url: ogUrl.toString(), width: 1200, height: 630}],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import {openapi} from '@/lib/openapi';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const schemas = await openapi.getSchemas();
|
||||
const first = Object.values(schemas)[0];
|
||||
if (!first) return new Response('Not found', {status: 404});
|
||||
|
||||
return new Response(JSON.stringify(first.bundled), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=300, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -27,12 +27,18 @@ Invalid request format or parameters. Check the error details for specific issue
|
||||
### 401 Unauthorized
|
||||
Authentication failed or missing. Verify your API key.
|
||||
|
||||
### 402 Payment Required
|
||||
A billing limit has been reached or the operation requires a plan upgrade. Returned with `BILLING_LIMIT_EXCEEDED` and `UPGRADE_REQUIRED`.
|
||||
|
||||
### 403 Forbidden
|
||||
Not authorized to access this resource. Check permissions or project status.
|
||||
Not authorized to access this resource. Check permissions or project status. Also returned when email verification is required (`EMAIL_VERIFICATION_REQUIRED`) or the project has been disabled (`PROJECT_DISABLED`).
|
||||
|
||||
### 404 Not Found
|
||||
The requested resource does not exist. Verify the resource ID.
|
||||
|
||||
### 409 Conflict
|
||||
The request conflicts with the current state of a resource — for example, creating a contact with an email that already exists, or updating to an email that's already taken. Returned with `CONFLICT`.
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
Request validation failed. Check the `errors` array for field-level details.
|
||||
|
||||
@@ -96,6 +102,7 @@ All errors include a machine-readable `code` field for programmatic handling. He
|
||||
| `FORBIDDEN` | 403 | Not allowed to perform this action |
|
||||
| `PROJECT_ACCESS_DENIED` | 403 | No access to this project |
|
||||
| `PROJECT_DISABLED` | 403 | Project has been disabled |
|
||||
| `EMAIL_VERIFICATION_REQUIRED` | 403 | The user's email address must be verified before this action |
|
||||
|
||||
### Validation & Input Errors
|
||||
|
||||
@@ -442,55 +449,56 @@ if (!data.success) {
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### Authentication Issues (401)
|
||||
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
|
||||
|
||||
**Problem**: `INVALID_API_KEY` or `MISSING_AUTH`
|
||||
<Accordions type="single">
|
||||
|
||||
**Solutions**:
|
||||
- Verify your API key is copied correctly (no extra spaces)
|
||||
- Check you're using the right key type (`sk_` for secret, `pk_` for public)
|
||||
- Ensure the `Authorization` header uses Bearer token format
|
||||
- Verify the key hasn't been revoked or regenerated
|
||||
<Accordion title="Authentication Issues (401) — INVALID_API_KEY or MISSING_AUTH">
|
||||
|
||||
#### Validation Issues (422)
|
||||
- Verify your API key is copied correctly (no extra spaces).
|
||||
- Check you're using the right key type (`sk_` for secret, `pk_` for public).
|
||||
- Ensure the `Authorization` header uses Bearer token format.
|
||||
- Verify the key hasn't been revoked or regenerated.
|
||||
|
||||
**Problem**: `VALIDATION_ERROR` with field errors
|
||||
</Accordion>
|
||||
|
||||
**Solutions**:
|
||||
- Check the `errors` array for specific field issues
|
||||
- Verify all required fields are included
|
||||
- Ensure field types match (strings quoted, numbers unquoted)
|
||||
- Review the API reference for correct request format
|
||||
<Accordion title="Validation Issues (422) — VALIDATION_ERROR">
|
||||
|
||||
#### Not Found Issues (404)
|
||||
- Check the `errors` array for specific field issues.
|
||||
- Verify all required fields are included.
|
||||
- Ensure field types match (strings quoted, numbers unquoted).
|
||||
- Review the API reference for the correct request format.
|
||||
|
||||
**Problem**: `TEMPLATE_NOT_FOUND`, `CONTACT_NOT_FOUND`, etc.
|
||||
</Accordion>
|
||||
|
||||
**Solutions**:
|
||||
- Verify the resource ID is correct
|
||||
- Check the resource belongs to your project
|
||||
- Ensure the resource hasn't been deleted
|
||||
- List available resources via the API to confirm IDs
|
||||
<Accordion title="Not Found Issues (404) — TEMPLATE_NOT_FOUND, CONTACT_NOT_FOUND, etc.">
|
||||
|
||||
#### Rate Limit Issues (429)
|
||||
- Verify the resource ID is correct.
|
||||
- Check the resource belongs to your project.
|
||||
- Ensure the resource hasn't been deleted.
|
||||
- List available resources via the API to confirm IDs.
|
||||
|
||||
**Problem**: `RATE_LIMIT_EXCEEDED`
|
||||
</Accordion>
|
||||
|
||||
**Solutions**:
|
||||
- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries)
|
||||
- Reduce request frequency
|
||||
- Consider upgrading your plan for higher limits
|
||||
- Batch operations when possible
|
||||
<Accordion title="Rate Limit Issues (429) — RATE_LIMIT_EXCEEDED">
|
||||
|
||||
#### Server Errors (500)
|
||||
- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries).
|
||||
- Reduce request frequency.
|
||||
- Consider upgrading your plan for higher limits.
|
||||
- Batch operations when possible.
|
||||
|
||||
**Problem**: `INTERNAL_SERVER_ERROR`
|
||||
</Accordion>
|
||||
|
||||
**Solutions**:
|
||||
- Note the request ID from the error response
|
||||
- Wait a moment and retry the request
|
||||
- Check [status.useplunk.com](https://status.useplunk.com) for incidents
|
||||
- Contact support with the request ID if the issue persists
|
||||
<Accordion title="Server Errors (500) — INTERNAL_SERVER_ERROR">
|
||||
|
||||
- Note the request ID from the error response.
|
||||
- Wait a moment and retry the request.
|
||||
- Check [status.useplunk.com](https://status.useplunk.com) for incidents.
|
||||
- Contact support with the request ID if the issue persists.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</Accordions>
|
||||
|
||||
### Best Practices
|
||||
|
||||
|
||||
@@ -67,11 +67,9 @@ curl -X POST {{API_URL}}/contacts \
|
||||
|
||||
## Response format
|
||||
|
||||
All API responses follow a standardized format for easy parsing and error handling.
|
||||
### Success responses
|
||||
|
||||
### Success response
|
||||
|
||||
Public API endpoints (`/v1/send`, `/v1/track`):
|
||||
Public API endpoints (`/v1/send`, `/v1/track`, `/v1/verify`) return a wrapped envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -84,30 +82,24 @@ Public API endpoints (`/v1/send`, `/v1/track`):
|
||||
}
|
||||
```
|
||||
|
||||
Dashboard API endpoints (contacts, templates, campaigns):
|
||||
Dashboard endpoints (contacts, templates, campaigns, segments, workflows, etc.) return the resource directly — no `success`/`data` wrapper:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "cnt_abc123",
|
||||
"email": "user@example.com",
|
||||
"createdAt": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
"id": "cnt_abc123",
|
||||
"email": "user@example.com",
|
||||
"createdAt": "2025-11-30T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
List endpoints with pagination:
|
||||
List endpoints with cursor pagination return:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [...],
|
||||
"nextCursor": "abc123",
|
||||
"hasMore": true,
|
||||
"total": 1000
|
||||
}
|
||||
"data": [ /* items */ ],
|
||||
"cursor": "def456",
|
||||
"hasMore": true,
|
||||
"total": 10000
|
||||
}
|
||||
```
|
||||
|
||||
@@ -148,35 +140,37 @@ See the [Error Codes documentation](/api-reference/errors) for complete details
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints support cursor-based pagination:
|
||||
Most list endpoints use **cursor-based** pagination:
|
||||
|
||||
```bash
|
||||
GET /contacts?limit=100&cursor=abc123
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `limit` — Number of items per page (default: 20, max: 100)
|
||||
- `cursor` — Pagination cursor from previous response
|
||||
- `limit` — items per page (default: 20, max: 100)
|
||||
- `cursor` — pagination cursor from the previous response's `cursor` field
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"nextCursor": "def456",
|
||||
"data": [ /* items */ ],
|
||||
"cursor": "def456",
|
||||
"hasMore": true,
|
||||
"total": 10000
|
||||
}
|
||||
```
|
||||
|
||||
Use `nextCursor` for the next page. When `hasMore` is false, you've reached the end.
|
||||
Pass the response's `cursor` value as the next request's `cursor` query parameter. When `hasMore` is `false`, you've reached the end. The `total` count is only included on the first page (when no `cursor` is supplied) — subsequent pages return `total: 0` to keep listing fast.
|
||||
|
||||
A few endpoints (e.g. `GET /segments/:id/contacts`) use **page-based** pagination instead, with `page` and `pageSize` parameters. Their responses include `total`, `page`, and `pageSize` fields.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Plunk enforces reasonable rate limits to ensure service quality:
|
||||
|
||||
- **Email sending** — 14 emails/second (AWS SES default)
|
||||
- **API requests** — 1000 requests/minute per project
|
||||
- **Bulk operations** — Automatically queued for processing
|
||||
- **Email sending** — throttled per project to protect deliverability.
|
||||
- **API requests** — 1000 requests/minute per project.
|
||||
- **Bulk operations** — automatically queued for asynchronous processing.
|
||||
|
||||
If you exceed limits, you'll receive a `429 Too Many Requests` response.
|
||||
|
||||
@@ -202,75 +196,200 @@ For a complete list of error codes and troubleshooting guidance, see the [Error
|
||||
|
||||
## API endpoints
|
||||
|
||||
### Public API (transactional)
|
||||
A complete, grouped reference of every endpoint exposed by the Plunk API.
|
||||
|
||||
**POST /v1/send** — Send transactional email(s)
|
||||
- Accepts single or multiple recipients
|
||||
- Template or inline content
|
||||
- Variable substitution
|
||||
### Public API
|
||||
|
||||
**POST /v1/track** — Track event for contact
|
||||
- Creates/updates contact
|
||||
- Tracks custom event
|
||||
- Can use public key
|
||||
The `/v1/*` endpoints are designed for use from your applications and accept simple, denormalised payloads. `POST /v1/track` is the only endpoint callable with a public (`pk_*`) key.
|
||||
|
||||
| Method | Path | Description | Key |
|
||||
| ------ | ------------ | -------------------------------------------------------------------------------------------- | -------- |
|
||||
| POST | `/v1/send` | Send a transactional email. Single or multiple recipients, template or inline content, attachments, headers, custom data. | `sk_*` |
|
||||
| POST | `/v1/track` | Track an event for a contact. Auto-creates or upserts the contact. Triggers workflows. | `pk_*` or `sk_*` |
|
||||
| POST | `/v1/verify` | Validate an email address — format, MX records, disposable domains, typo detection. | `sk_*` |
|
||||
|
||||
### Contacts
|
||||
|
||||
**GET /contacts** — List all contacts
|
||||
**POST /contacts** — Create new contact
|
||||
**GET /contacts/:id** — Get contact details
|
||||
**PATCH /contacts/:id** — Update contact
|
||||
**DELETE /contacts/:id** — Delete contact
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/contacts` | List contacts. Supports `search` (by email substring), `limit`, `cursor`. |
|
||||
| POST | `/contacts` | Create or upsert a contact by email. Returns `_meta.isNew` and `_meta.isUpdate`. |
|
||||
| GET | `/contacts/:id` | Get a single contact. |
|
||||
| PATCH | `/contacts/:id` | Update a contact's email, subscription state, or `data` fields. |
|
||||
| DELETE | `/contacts/:id` | Delete a contact. |
|
||||
| POST | `/contacts/lookup` | Bulk email-existence check (max 500 emails per call). |
|
||||
| **Custom fields** | | |
|
||||
| GET | `/contacts/fields` | List standard and custom fields with inferred types and coverage percentages. |
|
||||
| GET | `/contacts/fields/:field/values` | Distinct values for a custom field — used by segment / workflow filter UIs. |
|
||||
| GET | `/contacts/fields/:field/usage` | Where a custom field is referenced (segments, campaigns, workflows). |
|
||||
| DELETE | `/contacts/fields/:field` | Delete a custom field across every contact in the project. |
|
||||
| **CSV import** | | |
|
||||
| POST | `/contacts/import` | Upload a CSV file (multipart, ≤ 5 MB). Queued — returns a `jobId`. |
|
||||
| GET | `/contacts/import/:jobId` | Poll the status of a CSV import job. |
|
||||
| **Bulk operations** | | |
|
||||
| POST | `/contacts/bulk-subscribe` | Subscribe up to 1,000 contacts by ID. Queued — returns a `jobId`. |
|
||||
| POST | `/contacts/bulk-unsubscribe` | Unsubscribe up to 1,000 contacts by ID. Queued. |
|
||||
| POST | `/contacts/bulk-delete` | Delete up to 1,000 contacts by ID. Queued. |
|
||||
| GET | `/contacts/bulk/:jobId` | Poll the status of a bulk job. |
|
||||
|
||||
### Templates
|
||||
|
||||
**GET /templates** — List all templates
|
||||
**POST /templates** — Create new template
|
||||
**GET /templates/:id** — Get template details
|
||||
**PATCH /templates/:id** — Update template
|
||||
**DELETE /templates/:id** — Delete template
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/templates` | List all templates. |
|
||||
| POST | `/templates` | Create a template. `from` must be on a verified domain. |
|
||||
| GET | `/templates/:id` | Get a template. |
|
||||
| PATCH | `/templates/:id` | Update a template. |
|
||||
| DELETE | `/templates/:id` | Delete a template. |
|
||||
| POST | `/templates/:id/duplicate` | Duplicate a template — returns the new template ID. |
|
||||
| GET | `/templates/:id/usage` | List campaigns and workflow steps that reference this template. |
|
||||
|
||||
### Campaigns
|
||||
|
||||
**GET /campaigns** — List all campaigns
|
||||
**POST /campaigns** — Create new campaign
|
||||
**GET /campaigns/:id** — Get campaign details
|
||||
**PATCH /campaigns/:id** — Update campaign
|
||||
**POST /campaigns/:id/send** — Send or schedule campaign
|
||||
**POST /campaigns/:id/cancel** — Cancel scheduled campaign
|
||||
**POST /campaigns/:id/test** — Send test email
|
||||
**GET /campaigns/:id/stats** — Get campaign analytics
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/campaigns` | List all campaigns. |
|
||||
| POST | `/campaigns` | Create a campaign in `DRAFT`. `from` must be on a verified domain. |
|
||||
| GET | `/campaigns/:id` | Get a campaign. |
|
||||
| PUT | `/campaigns/:id` | Update a campaign (replace). |
|
||||
| DELETE | `/campaigns/:id` | Delete a campaign. Returns 409 if it has active executions. |
|
||||
| POST | `/campaigns/:id/duplicate` | Duplicate a campaign — returns the new campaign in `DRAFT`. |
|
||||
| POST | `/campaigns/:id/send` | Send (or schedule) the campaign. Pass `scheduledFor` for delayed sends. |
|
||||
| POST | `/campaigns/:id/cancel` | Cancel a `SCHEDULED` or `SENDING` campaign. |
|
||||
| POST | `/campaigns/:id/test` | Send a test email to a single address (`{ email: "you@example.com" }`). |
|
||||
| GET | `/campaigns/:id/stats` | Get current send / open / click / bounce counts. |
|
||||
|
||||
### Segments
|
||||
|
||||
**GET /segments** — List all segments
|
||||
**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)
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/segments` | List all segments (no pagination — small list). |
|
||||
| POST | `/segments` | Create a segment. `type: "DYNAMIC"` requires `condition`; `type: "STATIC"` rejects it. |
|
||||
| GET | `/segments/:id` | Get a segment, including cached `memberCount`. |
|
||||
| PATCH | `/segments/:id` | Update name, description, condition (dynamic only), or `trackMembership`. |
|
||||
| DELETE | `/segments/:id` | Delete a segment. Returns 409 if used by an active campaign. |
|
||||
| GET | `/segments/:id/contacts` | Page-based list of segment members. `page`, `pageSize` (max 100). Live for dynamic segments. |
|
||||
| POST | `/segments/:id/members` | Add emails to a **static** segment. Body: `{ emails, createMissing?, subscribed? }`. |
|
||||
| DELETE | `/segments/:id/members` | Remove emails from a **static** segment. Body: `{ emails }`. |
|
||||
| POST | `/segments/:id/compute` | Recompute membership for a tracked dynamic segment — fires entry/exit events. |
|
||||
| POST | `/segments/:id/refresh` | Cheap count refresh — no events, no membership writes. |
|
||||
|
||||
### Workflows
|
||||
|
||||
**GET /workflows** — List all workflows
|
||||
**POST /workflows** — Create new workflow
|
||||
**GET /workflows/:id** — Get workflow details
|
||||
**PATCH /workflows/:id** — Update workflow
|
||||
**DELETE /workflows/:id** — Delete workflow
|
||||
**GET /workflows/:id/executions** — List workflow executions
|
||||
Each workflow consists of a **workflow** record, a graph of **steps**, **transitions** between them, and per-contact **executions**. The API mirrors that structure.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| GET | `/workflows` | List all workflows. |
|
||||
| GET | `/workflows/fields` | Fields available to use in `CONDITION` step filters (contact + event fields). |
|
||||
| POST | `/workflows` | Create a workflow. Always starts with `triggerType: EVENT` and `enabled: false`. |
|
||||
| GET | `/workflows/:id` | Get a workflow with its steps and transitions. |
|
||||
| PATCH | `/workflows/:id` | Update workflow metadata, trigger type / config, `enabled`, `allowReentry`. |
|
||||
| DELETE | `/workflows/:id` | Delete a workflow. Active executions must be cancelled or completed first. |
|
||||
| **Steps** | | |
|
||||
| POST | `/workflows/:id/steps` | Add a step (`SEND_EMAIL`, `DELAY`, `WAIT_FOR_EVENT`, `CONDITION`, `WEBHOOK`, `UPDATE_CONTACT`, `EXIT`). |
|
||||
| PATCH | `/workflows/:id/steps/:stepId` | Update a step's config. |
|
||||
| DELETE | `/workflows/:id/steps/:stepId?splice=true` | Delete a step. Pass `splice=true` to auto-reconnect surrounding transitions. |
|
||||
| **Transitions** | | |
|
||||
| POST | `/workflows/:id/transitions` | Add a transition between two steps. For `CONDITION` steps, include `branch: "yes" \| "no"`. |
|
||||
| DELETE | `/workflows/:id/transitions/:transitionId` | Delete a transition. |
|
||||
| **Executions** | | |
|
||||
| POST | `/workflows/:id/executions` | Manually start an execution for a contact. Optional `context` JSON for per-execution variables. |
|
||||
| GET | `/workflows/:id/executions` | List executions, filterable by `status`. |
|
||||
| GET | `/workflows/:id/executions/:executionId` | Get a single execution. |
|
||||
| DELETE | `/workflows/:id/executions/:executionId` | Cancel a running or waiting execution. |
|
||||
| POST | `/workflows/:id/executions/cancel-all` | Cancel every active execution at once. |
|
||||
|
||||
### Events
|
||||
|
||||
**GET /events** — List all events
|
||||
**GET /events/names** — List unique event names
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| POST | `/events/track` | Internal alias for `/v1/track`, for dashboard use. Use `/v1/track` from your apps. |
|
||||
| GET | `/events` | List recent tracked events for a project. |
|
||||
| GET | `/events/stats` | Aggregated event statistics. |
|
||||
| GET | `/events/contact/:contactId` | All events for a single contact. |
|
||||
| GET | `/events/names` | All distinct event names tracked in this project. |
|
||||
| GET | `/events/:eventName/usage` | Where an event name is referenced (segment filters, workflow triggers, conditions). |
|
||||
| DELETE | `/events/:eventName` | Delete every event with the given name from the project. |
|
||||
|
||||
### Domains
|
||||
|
||||
**GET /domains** — List verified domains
|
||||
**POST /domains** — Add domain for verification
|
||||
**DELETE /domains/:id** — Remove domain
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/domains/project/:projectId` | List domains configured for a project (verified and pending). |
|
||||
| POST | `/domains` | Add a domain for verification. Returns the DNS records you need to add. |
|
||||
| GET | `/domains/:id/verify` | Force a verification check now (otherwise checked every 5 minutes in the background). |
|
||||
| DELETE | `/domains/:id` | Remove a domain. |
|
||||
|
||||
### Activity & analytics
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| GET | `/activity` | Cross-resource activity feed (sends, opens, clicks, bounces, complaints, inbound, etc.). |
|
||||
| GET | `/activity/stats` | Aggregated counts for dashboard charts. |
|
||||
| GET | `/activity/recent-count` | Recent event count for the dashboard's "live" indicator. |
|
||||
| GET | `/activity/types` | Distinct activity types in the project. |
|
||||
| GET | `/activity/upcoming` | Upcoming scheduled sends and active executions. |
|
||||
| GET | `/analytics/timeseries` | Email send / open / click time-series. |
|
||||
| GET | `/analytics/top-campaigns` | Top-performing campaigns by metric. |
|
||||
| GET | `/analytics/campaign-stats` | Campaign-level breakdown. |
|
||||
| GET | `/analytics/top-events` | Most frequent custom event names. |
|
||||
|
||||
### Uploads
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| POST | `/uploads/image` | Upload an image (multipart) for use inside template bodies. Returns a public URL. |
|
||||
|
||||
### Authentication & user management
|
||||
|
||||
The dashboard authenticates with JWT cookies; these endpoints mostly aren't useful from server-to-server integrations but are documented here for completeness.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| POST | `/auth/login` | Email + password login. Sets a JWT cookie. |
|
||||
| POST | `/auth/signup` | Sign up a new user (subject to `DISABLE_SIGNUPS`). |
|
||||
| GET | `/auth/logout` | Clear the auth cookie. |
|
||||
| GET | `/auth/oauth-config` | Which OAuth providers are configured. |
|
||||
| POST | `/auth/verify-email` | Verify an email with a token from an email link. |
|
||||
| POST | `/auth/request-verification` | Resend the verification email. |
|
||||
| POST | `/auth/request-password-reset` | Send a password reset email. |
|
||||
| POST | `/auth/reset-password` | Reset a password with a token. |
|
||||
| GET | `/users/@me` | Get the current user. |
|
||||
| GET | `/users/@me/projects` | List the user's projects. |
|
||||
| POST | `/users/@me/projects` | Create a project. |
|
||||
| PATCH | `/users/@me/projects/:id` | Update project settings. |
|
||||
| POST | `/users/@me/projects/:id/regenerate-keys` | Rotate both API keys. |
|
||||
| POST | `/users/@me/projects/:id/checkout` | Create a Stripe Checkout session. |
|
||||
| POST | `/users/@me/projects/:id/billing-portal` | Open the Stripe billing portal. |
|
||||
| GET | `/users/@me/projects/:id/billing-limits` | Read per-category billing caps. |
|
||||
| PUT | `/users/@me/projects/:id/billing-limits` | Update per-category billing caps. |
|
||||
| GET | `/users/@me/projects/:id/billing-consumption` | Current period consumption for the project. |
|
||||
| GET | `/users/@me/projects/:id/billing-invoices` | Stripe invoices for the project. |
|
||||
| GET | `/users/@me/projects/:id/security` | Security info — bounce/complaint rates, recent suspensions. |
|
||||
| POST | `/users/@me/projects/:id/reset` | Wipe project data (irreversible). |
|
||||
| DELETE | `/users/@me/projects/:id` | Delete the project entirely. |
|
||||
| GET | `/projects/:id/setup-state` | Onboarding setup state. |
|
||||
| GET | `/projects/:id/security` | Security state for a single project. |
|
||||
| GET | `/projects/:id/members` | Project team members. |
|
||||
| POST | `/projects/:id/members` | Invite a team member. |
|
||||
| PATCH | `/projects/:id/members/:userId` | Change a member's role. |
|
||||
| DELETE | `/projects/:id/members/:userId` | Remove a member. |
|
||||
|
||||
### Configuration
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| GET | `/config` | Public, no-auth feature flags — which integrations are enabled (OAuth providers, billing, S3, SMTP, …). |
|
||||
|
||||
### Internal webhook endpoints
|
||||
|
||||
These endpoints receive events from the underlying email and billing infrastructure. You don't call them from your applications — they're listed for completeness for self-hosters.
|
||||
|
||||
| Method | Path |
|
||||
| ------ | ----------------------------- |
|
||||
| POST | `/webhooks/sns` |
|
||||
| POST | `/webhooks/incoming/stripe` |
|
||||
|
||||
## Client libraries
|
||||
|
||||
|
||||
@@ -1,19 +1,89 @@
|
||||
---
|
||||
title: Billing
|
||||
description: Understand Plunk's billing model
|
||||
description: How Plunk's pricing, limits, and consumption work
|
||||
icon: CreditCard
|
||||
---
|
||||
|
||||
Plunk's pricing is based on the number of emails sent each month. You can send both marketing and transactional emails under the same plan.
|
||||
Plunk's pricing is based on the number of emails sent each month. Marketing emails, transactional emails, workflow sends, and inbound emails are all counted under the same monthly usage. Pricing on this page applies to the hosted version at [useplunk.com](https://www.useplunk.com); self-hosted instances run on their own infrastructure.
|
||||
|
||||
## Free Tier
|
||||
Free tier projects can send up to 1,000 emails per month at no cost. Projects on this tier will include a Plunk-branded footer in all emails.
|
||||
## Plans
|
||||
|
||||
## Pay-as-you-go
|
||||
After upgrading from the free tier, you will be charged per email sent at $0.001 per email. There are no monthly fees or commitments, you only pay for what you use.
|
||||
### Free tier
|
||||
|
||||
You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings.
|
||||
Free tier projects can send up to 1,000 emails per month at no cost. Free-tier projects include a Plunk-branded footer in marketing emails. Inbound emails count toward the 1,000-email allowance.
|
||||
|
||||
## Special considerations
|
||||
- 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)
|
||||
### Pay-as-you-go
|
||||
|
||||
After upgrading from the free tier, you're charged per email sent at $0.001 per email. There are no monthly fees and no commitments — you only pay for what you use.
|
||||
|
||||
You can monitor consumption and set per-category caps under **Settings → Billing**.
|
||||
|
||||
## What counts as an email
|
||||
|
||||
Every send and inbound receive contributes to your monthly usage:
|
||||
|
||||
| Source | Cost |
|
||||
| ------------------------------- | ------------------------------------ |
|
||||
| Transactional sends (`/v1/send`) | 1 credit per recipient |
|
||||
| Campaigns | 1 credit per recipient |
|
||||
| Workflow `SEND_EMAIL` steps | 1 credit per send |
|
||||
| Inbound emails | 1 credit per received email |
|
||||
| **Emails with attachments** | **2 credits** per email |
|
||||
|
||||
Emails with one or more attachments cost double. There's no extra charge based on attachment size — one credit per attached email regardless of whether it's 100 KB or 10 MB.
|
||||
|
||||
## Per-category billing limits
|
||||
|
||||
Set monthly caps per category to control runaway sends from any single source. This is useful if, for example, you want to ensure transactional capacity is always available even if a campaign accidentally over-sends.
|
||||
|
||||
| Category | What it caps |
|
||||
| --------------- | ------------------------------------------------------------- |
|
||||
| Transactional | Emails sent through `/v1/send` |
|
||||
| Campaigns | Emails sent as part of a campaign |
|
||||
| Workflows | Emails sent by `SEND_EMAIL` steps inside a workflow |
|
||||
| Inbound | Inbound emails received at your verified domain |
|
||||
|
||||
Configure caps in **Settings → Billing → Limits**. Each category cap is independent — you can set just one, all of them, or none.
|
||||
|
||||
## What happens when a limit is hit
|
||||
|
||||
When you exceed a per-category cap or your overall plan allowance:
|
||||
|
||||
- **Outbound API calls** (`/v1/send`, campaign sends, workflow sends) return `402 Payment Required` with error code `BILLING_LIMIT_EXCEEDED`. Your application should handle this gracefully — typically by queuing the work or surfacing an error to the user.
|
||||
- **Inbound emails** are dropped silently for the affected project until the cap resets. They aren't queued or replayed.
|
||||
- Limits reset at the start of each billing period.
|
||||
|
||||
You'll also receive a notification (email and/or in-app) when a cap is approached or hit.
|
||||
|
||||
## Invoices and payment
|
||||
|
||||
Stripe handles billing. From the **Settings → Billing** page you can:
|
||||
|
||||
- Open the **Stripe billing portal** to update payment methods, addresses, and tax IDs.
|
||||
- View and download every invoice for the project.
|
||||
- See current period consumption with a breakdown by category.
|
||||
|
||||
If a payment fails, Stripe retries on its standard schedule. Repeated failures eventually pause the project's ability to send — keep your payment method up to date to avoid disruption.
|
||||
|
||||
## API reference
|
||||
|
||||
- `GET /users/@me/projects/:id/billing-consumption` — current period usage by category.
|
||||
- `GET /users/@me/projects/:id/billing-limits` — read per-category caps.
|
||||
- `PUT /users/@me/projects/:id/billing-limits` — update per-category caps.
|
||||
- `GET /users/@me/projects/:id/billing-invoices` — list invoices.
|
||||
- `POST /users/@me/projects/:id/billing-portal` — generate a Stripe billing portal URL.
|
||||
- `POST /users/@me/projects/:id/checkout` — start a Stripe Checkout session for upgrades.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="List hygiene" href="/guides/list-hygiene">
|
||||
Reduce wasted sends by keeping bounce and complaint rates low.
|
||||
</Card>
|
||||
<Card title="Receiving emails" href="/guides/receiving-emails">
|
||||
Inbound emails count toward your monthly usage.
|
||||
</Card>
|
||||
<Card title="Self-hosting" href="/self-hosting/introduction">
|
||||
Run Plunk on your own infrastructure for full control over costs.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -1,15 +1,93 @@
|
||||
---
|
||||
title: Campaigns
|
||||
description: Broadcast to your contacts
|
||||
description: One-off broadcast emails sent to a defined audience
|
||||
icon: Megaphone
|
||||
---
|
||||
|
||||
Campaigns are a one-time email sent to a group of contacts. They are typically used for newsletters, announcements, or promotions.
|
||||
Campaigns are one-off email sends to a defined audience — newsletters, announcements, product launches, promotions. Unlike workflows, a campaign sends once at a single point in time (immediately or at a scheduled time) and is then frozen.
|
||||
|
||||
## Targeting contacts
|
||||
When you create a campaign, you can send it to all subscribed contacts, or target a specific segment you have created in the [Segments](/concepts/segments) section.
|
||||
## Anatomy of a campaign
|
||||
|
||||
## Sending a campaign
|
||||
Once you have designed your campaign and selected the target audience, you can schedule it to be sent immediately or at a later time. You can also choose to send a test email to yourself or any other team member before sending it to your contacts.
|
||||
A campaign captures everything needed to render and deliver one batch of emails:
|
||||
|
||||
Campaigns will be queued and sent in the background. You can monitor the sending progress and view detailed analytics on opens, clicks, and bounces in the campaign detail page. Depending on the size of your audience, it may take some time for all emails to be sent.
|
||||
import {TypeTable} from 'fumadocs-ui/components/type-table';
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
name: { type: 'string', required: true, description: 'Internal name shown in the dashboard.' },
|
||||
description: { type: 'string', description: 'Optional internal note.' },
|
||||
subject: { type: 'string', required: true, description: 'Email subject line. Supports the same template variables as [Templates](/concepts/templates).' },
|
||||
body: { type: 'string', required: true, description: 'HTML body. Supports template variables.' },
|
||||
from: { type: 'string', required: true, description: 'Sender email — must be on a verified domain.' },
|
||||
fromName: { type: 'string', description: 'Optional sender display name (e.g. `Acme Marketing <hello@acme.com>`).' },
|
||||
replyTo: { type: 'string', description: 'Optional reply-to address.' },
|
||||
type: { type: 'string', required: true, description: 'One of `MARKETING`, `TRANSACTIONAL`, or `HEADLESS`. Same semantics as [template types](/concepts/templates#templates-types). Controls the unsubscribe footer and whether unsubscribed contacts are skipped.' },
|
||||
audienceType: { type: 'string', required: true, description: 'Who this campaign is sent to. One of `ALL`, `SEGMENT`, or `FILTERED` — see below.' },
|
||||
segmentId: { type: 'string', description: 'Required when `audienceType = SEGMENT`.' },
|
||||
audienceCondition: { type: 'object', description: 'Inline filter when `audienceType = FILTERED`. Uses the same format as [segment filters](/guides/segment-filters).' },
|
||||
scheduledFor: { type: 'string', description: 'ISO 8601 timestamp. If set, the campaign sends at that time; if null, it sends immediately when you trigger send.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
The campaign also keeps denormalised stats once it sends: `totalRecipients`, `sentCount`, `deliveredCount`, `openedCount`, `clickedCount`, `bouncedCount`. Fetch them via `GET /campaigns/:id/stats`.
|
||||
|
||||
## Targeting an audience
|
||||
|
||||
Campaigns support three audience types:
|
||||
|
||||
- **`ALL`** — every subscribed contact in the project. Quick way to broadcast to your full list.
|
||||
- **`SEGMENT`** — every contact that's currently a member of a specific [segment](/concepts/segments). Reuses logic you've already built.
|
||||
- **`FILTERED`** — an inline filter condition (same JSON shape as segment filters). Lets you hand-craft an audience for a one-off send without saving a reusable segment.
|
||||
|
||||
`MARKETING` and `HEADLESS` campaigns automatically skip unsubscribed contacts. `TRANSACTIONAL` campaigns deliver to everyone matching the audience regardless of subscription state — use it sparingly and only for genuine transactional content.
|
||||
|
||||
## Sender domain verification
|
||||
|
||||
The `from` address must be on a domain you've verified for sending. Creating or updating a campaign with an unverified `from` returns `403`. See [Verifying domains](/guides/verifying-domains).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------- | ----------------------------------------------------------------------------- |
|
||||
| `DRAFT` | Created but not yet scheduled or sent. Editable. |
|
||||
| `SCHEDULED` | Scheduled for a future time via `scheduledFor`. Cancellable. |
|
||||
| `SENDING` | Send is in progress — the queue is processing recipients. |
|
||||
| `SENT` | Send is complete. Stats are final. |
|
||||
| `CANCELLED` | Cancelled before completion. Terminal state from `DRAFT`, `SCHEDULED`, or `SENDING`. |
|
||||
|
||||
Once a campaign is `SENT` or `CANCELLED` it can't be edited or resent — duplicate it instead with `POST /campaigns/:id/duplicate` to create an editable copy.
|
||||
|
||||
## Sending and scheduling
|
||||
|
||||
Trigger a send with `POST /campaigns/:id/send`. The campaign is queued and sent in the background; depending on the audience size, this can take seconds for small lists to longer for very large ones. You can monitor progress on the campaign detail page in the dashboard.
|
||||
|
||||
To cancel a `SCHEDULED` or `SENDING` campaign, call `POST /campaigns/:id/cancel`. Cancellation stops further sending but cannot recall emails that have already been handed off to the recipient's mail server.
|
||||
|
||||
## Test sends
|
||||
|
||||
Send a single test email with `POST /campaigns/:id/test`. Body: `{ email: "you@example.com" }` — a single address, not an array. Test sends use the same template variable resolution as the real send but always send to the address you specify, so they're useful for previewing personalisation against your own contact data.
|
||||
|
||||
## Duplicating
|
||||
|
||||
`POST /campaigns/:id/duplicate` creates a new `DRAFT` campaign with the same content, audience, and settings as the source. Useful for A/B variants and recurring sends.
|
||||
|
||||
## API reference
|
||||
|
||||
See the [API overview](/api-reference/overview#campaigns) for the full set of campaign endpoints — list, get, create, update, delete, send, cancel, duplicate, test, and stats.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Templates" href="/concepts/templates">
|
||||
Create reusable email designs to send through campaigns.
|
||||
</Card>
|
||||
<Card title="Segments" href="/concepts/segments">
|
||||
Build audiences for `audienceType: SEGMENT` campaigns.
|
||||
</Card>
|
||||
<Card title="Verifying domains" href="/guides/verifying-domains">
|
||||
Required before you can send from your own domain.
|
||||
</Card>
|
||||
<Card title="List hygiene" href="/guides/list-hygiene">
|
||||
Keep bounce and complaint rates healthy.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -10,10 +10,12 @@ Contacts in Plunk represent an individual email recipient. Each contact has an i
|
||||
|
||||
Contacts can be added to your Plunk project in several ways:
|
||||
|
||||
- Using [/v1/track](/api-reference/public-api/trackEvent), when tracking an event for a contact that does not yet exist, Plunk will automatically create it.
|
||||
- Using [/contacts](/api-reference/contacts/createContact), to create a single contact.
|
||||
- Import through CSV
|
||||
- Manually through the dashboard
|
||||
- Using [`/v1/track`](/api-reference/public-api/trackEvent) — tracking an event for a contact that doesn't exist yet automatically creates it (subscribed by default).
|
||||
- Using [`POST /contacts`](/api-reference/contacts/createContact) — create or upsert a single contact. Existing emails are **updated**, not rejected; the response includes a `_meta: { isNew, isUpdate }` block and uses status `201` for new contacts and `200` for updates.
|
||||
- Bulk **CSV import** via `POST /contacts/import` — upload a CSV (≤ 5 MB) and poll `GET /contacts/import/:jobId` for status. The first column must be `email`; any other columns map to keys under `data.*`.
|
||||
- Bulk **subscribe / unsubscribe / delete** via `POST /contacts/bulk-subscribe`, `bulk-unsubscribe`, and `bulk-delete` — each accepts up to 1,000 contact IDs and returns a job ID; poll `GET /contacts/bulk/:jobId` for progress.
|
||||
- Adding emails to a **static segment** with `POST /segments/:id/members` and `createMissing: true` will create any contacts that don't already exist.
|
||||
- Manually through the dashboard.
|
||||
|
||||
## Contact Data
|
||||
|
||||
@@ -21,18 +23,37 @@ You can associate custom data with each contact using key-value pairs. This data
|
||||
|
||||
### Data types
|
||||
|
||||
Contact data types are inferred based on the value provided:
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| String | Any text value |
|
||||
| Number | Numeric values, including integers and floats |
|
||||
| Boolean | True or false values |
|
||||
| Date | Date values in ISO 8601 format |
|
||||
Contact data types are inferred from the **first non-null value** Plunk sees for a given key in your project:
|
||||
|
||||
<Callout title="Default data type" variant="idea">
|
||||
If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string.
|
||||
| Type | Description |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| String | Any text value that doesn't look like a date. |
|
||||
| Number | Numeric values, including integers and floats. |
|
||||
| Boolean | `true` or `false`. |
|
||||
| Date | A string matching `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS[.sss]Z`. The full ISO 8601 form is required for date-aware operators in segments. |
|
||||
|
||||
The type is sampled per-project on first write — once a key is typed as `number`, subsequent string values for that key are still stored, but the **inferred type** stays `number` for the segment filter UI. If you accidentally mix types, the safest reset is to delete the field via `DELETE /contacts/fields/:field` and re-import with the type you want.
|
||||
|
||||
<Callout title="Default data type" type="info">
|
||||
If a value doesn't match number, boolean, or the ISO 8601 date format, it's typed as a string.
|
||||
</Callout>
|
||||
|
||||
### Non-persistent values
|
||||
|
||||
Send a value as `{ value, persistent: false }` to use it once for template rendering without storing it on the contact:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"data": {
|
||||
"firstName": "Ada",
|
||||
"resetCode": { "value": "ABC123", "persistent": false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`firstName` is saved on the contact; `resetCode` is available to the template for this send only and is then discarded. Use this for one-shot values like password reset codes, magic links, and one-time tokens.
|
||||
|
||||
### Special value handling
|
||||
|
||||
When creating or updating contacts, certain values are handled specially:
|
||||
@@ -43,20 +64,27 @@ When creating or updating contacts, certain values are handled specially:
|
||||
| `null` | Delete - field is removed from contact data | `{ name: null }` → Field is deleted |
|
||||
| Other values | Stored/updated normally | `{ name: "John" }` → Stored as "John" |
|
||||
|
||||
<Callout title="Removing contact data" variant="idea">
|
||||
<Callout title="Removing contact data" type="info">
|
||||
To remove a field from a contact's data, set it to `null` when creating or updating the contact. Empty strings are
|
||||
automatically filtered out and won't overwrite existing data.
|
||||
</Callout>
|
||||
|
||||
### Reserved keys
|
||||
|
||||
Certain keys are reserved by the system and automatically set by Plunk:
|
||||
| Key | Description |
|
||||
|-----|-------------|
|
||||
| email | The contact's email address |
|
||||
| createdAt | Timestamp of when the contact was created |
|
||||
| updatedAt | Timestamp of the last update to the contact |
|
||||
| subscribed | Boolean indicating if the contact is globally subscribed or not |
|
||||
Some keys are reserved by Plunk and managed at the contact level (not inside `data`). You can read them but you can't set them through `data`:
|
||||
|
||||
| Key | Description |
|
||||
| ------------ | ------------------------------------------------------------------------ |
|
||||
| `email` | The contact's email address |
|
||||
| `createdAt` | Timestamp of when the contact was created |
|
||||
| `updatedAt` | Timestamp of the last update |
|
||||
| `subscribed` | Boolean indicating whether the contact is subscribed to marketing emails |
|
||||
|
||||
The following keys are also **silently filtered out** of any `data` payload — sending them through `/v1/track`, `POST /contacts`, or `PATCH /contacts/:id` will not store them and will not return an error:
|
||||
|
||||
`id`, `plunk_id`, `plunk_email`, `unsubscribeUrl`, `subscribeUrl`, `manageUrl`
|
||||
|
||||
The last three are auto-generated per-recipient at send time and exposed as template variables (see [Templates](/concepts/templates)).
|
||||
|
||||
### Special keys
|
||||
|
||||
@@ -66,15 +94,25 @@ Certain keys are reserved by the system and automatically set by Plunk:
|
||||
|
||||
## Subscription State
|
||||
|
||||
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default.
|
||||
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default when created via `/v1/track`; contacts created via `POST /contacts` use whatever value you pass (default: `false`).
|
||||
|
||||
When you update a contact, **omitting `subscribed` keeps the current state** — it is not the same as passing `false`. To change the state, pass an explicit `true` or `false`.
|
||||
|
||||
Every flip of `subscribed` automatically tracks an event on the contact:
|
||||
|
||||
- `subscribed` flipped to `true` → `contact.subscribed` event
|
||||
- `subscribed` flipped to `false` → `contact.unsubscribed` event
|
||||
|
||||
These events fire on every path that changes subscription state — manual edits, the public unsubscribe page, bulk operations, automatic unsubscribes from bounces / complaints. You can branch on them in workflows.
|
||||
|
||||
### How contacts become unsubscribed
|
||||
|
||||
A contact can become unsubscribed in several ways:
|
||||
|
||||
- **Manually** through the dashboard or via the API
|
||||
- **Self-service** by clicking the unsubscribe link in an email
|
||||
- **Automatically** when an email to the contact bounces or results in a complaint
|
||||
- **Manually** through the dashboard or via the API.
|
||||
- **Self-service** by clicking the unsubscribe link in an email (powered by the public unsubscribe pages).
|
||||
- **Automatically** on a permanent (hard) email bounce. Soft bounces don't change subscription state.
|
||||
- **Automatically** when a recipient marks one of your emails as spam (their mailbox provider reports the complaint back to Plunk).
|
||||
|
||||
### Emails by subscription state
|
||||
|
||||
@@ -90,7 +128,24 @@ The subscription state controls whether a contact receives marketing emails. Tra
|
||||
| **Automations** (headless template) | Delivered | Not delivered |
|
||||
| **Automations** (transactional template) | Delivered | Delivered |
|
||||
|
||||
<Callout title="Transactional emails and marketing templates" variant="warn">
|
||||
<Callout title="Transactional emails and marketing templates" type="warn">
|
||||
Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed
|
||||
contact. Use a transactional template instead if the email must reach unsubscribed contacts.
|
||||
</Callout>
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Custom fields" href="/guides/custom-fields">
|
||||
How to set, type, and clean up arbitrary contact data.
|
||||
</Card>
|
||||
<Card title="Importing from CSV" href="/guides/importing-contacts">
|
||||
Bulk-load contacts and their custom fields.
|
||||
</Card>
|
||||
<Card title="Segments" href="/concepts/segments">
|
||||
Group contacts dynamically or statically for targeting.
|
||||
</Card>
|
||||
<Card title="Unsubscribe pages" href="/guides/unsubscribe-pages">
|
||||
Hosted unsubscribe / preferences pages and the URL template variables.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -6,51 +6,108 @@ icon: Layers
|
||||
|
||||
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**.
|
||||
|
||||
## Dynamic segments
|
||||
## Dynamic vs Static
|
||||
|
||||
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.
|
||||
| | Dynamic | Static |
|
||||
| ---------------------------- | ---------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Membership | Computed from filter conditions in real time | Manually curated — you decide who is in |
|
||||
| `condition` field | Required — describes the filters | Must be omitted |
|
||||
| Add/remove members via API | Not allowed (the filter decides) | `POST` / `DELETE` `/segments/:id/members` |
|
||||
| Membership recomputation | Re-evaluated in the background when tracked | N/A — membership only changes when you call the API |
|
||||
| Entry/exit events | Fired only when **Track membership changes** is enabled | Not fired by add/remove API calls |
|
||||
| Updating the filter | Member count is recomputed | `condition` is silently ignored on update |
|
||||
|
||||
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`, …)
|
||||
Use dynamic segments for behavioural targeting ("subscribed users on the Pro plan who opened any email in the last 14 days"). Use static segments for one-off curated lists like beta testers, conference attendees, or contacts imported from an external system.
|
||||
|
||||
Conditions can be combined with `AND`/`OR` logic and nested into groups for complex rules.
|
||||
## Filtering on a dynamic segment
|
||||
|
||||
A dynamic segment's membership is defined by a filter — a set of conditions evaluated against your contacts. You can filter on:
|
||||
|
||||
- Built-in contact fields: `email`, `subscribed`, `createdAt`, `updatedAt`.
|
||||
- Custom fields you've stored on contacts (anything under `data.*`).
|
||||
- Custom events tracked via `/v1/track` (`event.signed_up`, `event.purchased`, etc.).
|
||||
- Email engagement (`email.opened`, `email.clicked`, `email.bounced`, etc.).
|
||||
- Membership of another segment.
|
||||
|
||||
Filters can be combined with `AND` or `OR` and nested into groups for more complex audiences — for example "subscribed Pro users **and** (opened **or** clicked an email in the last 14 days)".
|
||||
|
||||
For the full list of fields, operators, and value types — plus worked examples — see the [Segment filter reference](/guides/segment-filters).
|
||||
|
||||
## 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.
|
||||
Static segments are manually curated lists. Membership doesn't change automatically — you decide exactly who is in.
|
||||
|
||||
## Creating a segment
|
||||
### Creating
|
||||
|
||||
Go to **Segments** in the dashboard and click **Create Segment**. Use the toggle at the top to choose **Dynamic** or **Static**.
|
||||
Go to **Segments** in the dashboard and click **Create Segment**, then choose **Static**. You can optionally add initial members straight away using the contact search.
|
||||
|
||||
**For dynamic segments**, use the filter builder to define your conditions. Plunk will show you a live count of matching contacts.
|
||||
### Adding and removing members
|
||||
|
||||
**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.
|
||||
Open a static segment and use the **Add Members** search to find 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.
|
||||
|
||||
## Managing static segment members
|
||||
Programmatically, use:
|
||||
|
||||
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.
|
||||
- `POST /segments/:id/members` — add contacts by email. Body: `{ emails: string[], createMissing?: boolean, subscribed?: boolean }`. With `createMissing: true`, contacts that don't exist yet are created (and start subscribed unless you pass `subscribed: false`). The response reports `{ added, created, notFound }`.
|
||||
- `DELETE /segments/:id/members` — remove contacts by email. Body: `{ emails: string[] }`. Returns `{ removed }`.
|
||||
|
||||
To remove a member, click the remove button on their row in the members list.
|
||||
Both endpoints **only** accept static segments. Calling them on a dynamic segment returns `400`.
|
||||
|
||||
## Track membership changes
|
||||
Membership changes via these API calls are immediate but **do not** fire `entry`/`exit` events — those are reserved for dynamic, tracked segments.
|
||||
|
||||
Both segment types support **Track membership changes**. When enabled, Plunk fires a webhook event each time a contact enters or leaves the segment:
|
||||
## Tracking membership changes
|
||||
|
||||
- `segment.trial-users.entry` — contact joined the segment
|
||||
- `segment.trial-users.exit` — contact left the segment
|
||||
Dynamic segments can opt into **Track membership changes**. When enabled, Plunk fires events whenever a contact enters or leaves the segment:
|
||||
|
||||
Where `trial-users` is derived from the segment name. See [Webhooks](/guides/webhooks) for the full event payload.
|
||||
- `segment.<slug>.entry` — contact joined
|
||||
- `segment.<slug>.exit` — contact left
|
||||
|
||||
The `<slug>` is derived from the segment name: lowercased, accents and punctuation stripped, whitespace replaced with hyphens, repeated hyphens collapsed. `"VIP Customers"` becomes `segment.vip-customers.entry`. Pick segment names that produce stable slugs — renaming a segment changes the event name.
|
||||
|
||||
Use these events to drive workflows (welcome a contact when they enter a "Trial users" segment, send a re-engagement email when they exit "Active users", etc.). See [Webhooks](/guides/webhooks) for the payload format.
|
||||
|
||||
### How tracking works
|
||||
|
||||
The member count updates immediately when you create or change a dynamic segment's filter. After that, Plunk recomputes membership in the background on a regular cadence — diffing current matches against the previous set, recording entries and exits, and emitting the corresponding events.
|
||||
|
||||
Because of this background cadence, the `memberCount` shown in the dashboard can lag the live state by a few minutes. If you need a fresh value or want to drive a workflow off `entry` / `exit` immediately:
|
||||
|
||||
- `POST /segments/:id/refresh` — force a count refresh (cheap, no events).
|
||||
- `POST /segments/:id/compute` — force a full membership recomputation, which fires any pending entry/exit events.
|
||||
|
||||
## Using segments
|
||||
|
||||
Segments can be used in:
|
||||
- Targeting contacts in [email campaigns](/concepts/campaigns)
|
||||
- Triggering workflows in [marketing automation](/concepts/workflows)
|
||||
Segments can be used to:
|
||||
|
||||
## Managing members via API
|
||||
- Target contacts in [email campaigns](/concepts/campaigns) (set the campaign audience to a segment)
|
||||
- Trigger workflows in [marketing automation](/concepts/workflows) (use the `segment.<slug>.entry` event)
|
||||
|
||||
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.
|
||||
## Performance notes
|
||||
|
||||
- Date filters on `data.*` rely on ISO 8601 string ordering — store dates as ISO 8601 strings (`2026-05-06T12:00:00Z`) rather than Unix timestamps if you want to use `within` / `olderThan` on them.
|
||||
- Nesting many untracked dynamic segments inside one another increases evaluation cost. If a segment is referenced by others, enable **Track membership** on it so its members are looked up directly instead of recomputed each time.
|
||||
- The cached `memberCount` may be a few minutes behind reality. Treat it as approximate; use `POST /segments/:id/refresh` to force a refresh if you need an exact count.
|
||||
|
||||
## Deleting a segment
|
||||
|
||||
Deleting a segment that's referenced by an active campaign (in `DRAFT`, `SCHEDULED`, or `SENDING` state) returns `409 Conflict`. Cancel the campaign or pick a different audience first.
|
||||
|
||||
## API reference
|
||||
|
||||
See the segments API endpoints in the [API overview](/api-reference/overview#segments) — list, get, create, update, delete, list members, add/remove static members, refresh count, and compute membership.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Filter reference" href="/guides/segment-filters">
|
||||
Deep-link reference for every filter field, operator, and value type.
|
||||
</Card>
|
||||
<Card title="Custom fields" href="/guides/custom-fields">
|
||||
How `data.*` fields are typed and used in filters.
|
||||
</Card>
|
||||
<Card title="Campaigns" href="/concepts/campaigns">
|
||||
Use a segment as a campaign audience.
|
||||
</Card>
|
||||
<Card title="Workflows" href="/concepts/workflows">
|
||||
Trigger workflows from segment entry / exit events.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -4,7 +4,33 @@ description: Create reusable email templates for your campaigns, workflows and t
|
||||
icon: SwatchBook
|
||||
---
|
||||
|
||||
Templates are stored in the Plunk dashboard and can be used in campaigns, workflows and transactional emails.
|
||||
Templates are stored in the Plunk dashboard and can be used in campaigns, workflows, and transactional emails sent through `/v1/send`.
|
||||
|
||||
## Anatomy of a template
|
||||
|
||||
A template bundles everything needed to render an email: content, sender, and behaviour.
|
||||
|
||||
import {TypeTable} from 'fumadocs-ui/components/type-table';
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
name: { type: 'string', required: true, description: 'Internal name shown in the dashboard.' },
|
||||
description: { type: 'string', description: 'Optional internal note.' },
|
||||
subject: { type: 'string', required: true, description: 'Email subject line. Supports template variables.' },
|
||||
body: { type: 'string', required: true, description: 'HTML body. Supports template variables.' },
|
||||
from: { type: 'string', required: true, description: 'Sender email — must be on a verified domain.' },
|
||||
fromName: { type: 'string', description: 'Optional sender display name.' },
|
||||
replyTo: { type: 'string', description: 'Optional reply-to address.' },
|
||||
type: { type: 'string', required: true, description: 'One of `MARKETING`, `TRANSACTIONAL`, or `HEADLESS`. See [Template types](#templates-types).' },
|
||||
style: { type: 'object', description: "Editor style metadata used by the dashboard's drag-and-drop editor. You can ignore this when creating templates via the API." },
|
||||
}}
|
||||
/>
|
||||
|
||||
When a template is used by `/v1/send`, the request can override any of `subject`, `body`, `from`, `fromName`, `replyTo` — the template's values act as defaults.
|
||||
|
||||
### Sender domain verification
|
||||
|
||||
The `from` address must be on a verified domain. Creating or updating a template with an unverified `from` returns `403`. See [Verifying domains](/guides/verifying-domains).
|
||||
|
||||
## Designing templates
|
||||
|
||||
@@ -49,6 +75,11 @@ The following fields are reserved by Plunk but can still be used.
|
||||
|
||||
You can preview your templates by selecting a contact in the preview window. This will allow you to see how the template will look for that specific contact, with their data populated.
|
||||
|
||||
## Duplicating and finding usage
|
||||
|
||||
- `POST /templates/:id/duplicate` — creates an editable copy. Useful for A/B variants or as a starting point for related emails.
|
||||
- `GET /templates/:id/usage` — lists every campaign, workflow step, and other reference that points at this template. Use this to answer "what breaks if I change this template?" or to find leftover references before deletion.
|
||||
|
||||
## Templates types
|
||||
|
||||
There are three types of templates in Plunk. Each type is treated at the same priority when sending emails, you should not pick one type over the other based on deliverability or performance.
|
||||
@@ -58,3 +89,20 @@ There are three types of templates in Plunk. Each type is treated at the same pr
|
||||
| Marketing | Yes | Yes | Automatically includes a Plunk-hosted unsubscribe footer. Will not be sent to contacts who are unsubscribed |
|
||||
| Transactional | No | No | Does not include any way to unsubscribe. Will be sent to any contact, regardless of subscription state |
|
||||
| Headless | Yes | No | Respects opt-out like marketing, but no Plunk footer is appended. You are responsible for providing an unsubscribe mechanism in the email body. Use `{{unsubscribeUrl}}` or `{{manageUrl}}` to link to Plunk's managed unsubscribe page |
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Transactional emails" href="/concepts/transactional-emails">
|
||||
Send templated emails directly through `/v1/send`.
|
||||
</Card>
|
||||
<Card title="Campaigns" href="/concepts/campaigns">
|
||||
Broadcast a template to a segment or audience.
|
||||
</Card>
|
||||
<Card title="Workflows" href="/concepts/workflows">
|
||||
Send templates as part of automated journeys.
|
||||
</Card>
|
||||
<Card title="Localization" href="/guides/localization">
|
||||
Translate the unsubscribe footer and contact-facing pages.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -9,12 +9,24 @@ Transactional emails are emails sent directly through the API. They are typicall
|
||||
## Sending with attachments
|
||||
Plunk supports sending attachments with transactional emails. By default, you can include up to 10 attachments per email with a maximum total size of 10MB. Attachments should be base64 encoded and included in the `attachments` array when sending the email via the [/v1/email/send](/api-reference/public-api/sendEmail) endpoint.
|
||||
|
||||
Self-hosters can raise these limits via environment variables to match the capacity of the underlying email provider (AWS SES supports up to 40MB per message by default):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `MAX_ATTACHMENT_SIZE_MB` | `10` | Maximum total attachment size in megabytes |
|
||||
| `MAX_ATTACHMENTS_COUNT` | `10` | Maximum number of attachments per email |
|
||||
The total message size cannot exceed 40 MB. Self-hosters can adjust the defaults — see [Environment variables](/self-hosting/environment-variables).
|
||||
|
||||
## Sending from a template
|
||||
You can also send transactional emails using a [template](/concepts/templates) you have created in the dashboard. This allows you to reuse the same design and content for multiple emails, while still personalizing them with contact data.
|
||||
You can also send transactional emails using a [template](/concepts/templates) you have created in the dashboard. This allows you to reuse the same design and content for multiple emails, while still personalizing them with contact data.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Send Email API" href="/api-reference/public-api/sendEmail">
|
||||
Full reference for `POST /v1/send`.
|
||||
</Card>
|
||||
<Card title="Templates" href="/concepts/templates">
|
||||
Author reusable email designs.
|
||||
</Card>
|
||||
<Card title="API keys" href="/guides/api-keys">
|
||||
Authenticating requests with `sk_*` and `pk_*` keys.
|
||||
</Card>
|
||||
<Card title="Webhooks" href="/guides/webhooks">
|
||||
Track delivery, opens, clicks, and bounces of your sends.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -1,41 +1,100 @@
|
||||
---
|
||||
title: Workflows
|
||||
description: Set up automated email sequences and trigger them from your apps
|
||||
description: Build automated, multi-step contact journeys triggered by events, segments, schedules, or manual entry
|
||||
icon: Workflow
|
||||
---
|
||||
|
||||
Workflows in Plunk allow you to create automated email sequences that can be triggered based on events you send using the API.
|
||||
Workflows are graph-based automations that move contacts through a sequence of steps — sending emails, waiting for activity, branching on conditions, calling webhooks, updating contact data, or exiting. Each contact that enters a workflow runs through it independently as a **workflow execution**.
|
||||
|
||||
## Prerequisites for workflows
|
||||
### Sending an event
|
||||
Workflows are triggered by sending events to Plunk using the [/v1/track](/api-reference/public-api/trackEvent) endpoint. When sending an event, you can specify the contact it is associated with and include any relevant data.
|
||||
## Triggers
|
||||
|
||||
### Creating a template
|
||||
Before setting up the workflow, ensure you have created a template that will be used for the emails sent by the workflow. You can create templates in the [Templates](/concepts/templates) section of the dashboard.
|
||||
A workflow has a single **trigger** that decides how contacts enter:
|
||||
|
||||
## Creating a workflow
|
||||
To create a workflow, navigate to the [Workflows](/concepts/workflows) section of the dashboard and create a workflow.
|
||||
| Trigger type | When it fires |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------ |
|
||||
| `EVENT` | Any time a matching event is tracked on a contact (custom event via `/v1/track`, system events such as `email.received` or `contact.subscribed`, or segment entry/exit events). |
|
||||
| `MANUAL` | Only when you explicitly start an execution via `POST /workflows/:id/executions`. |
|
||||
| `SCHEDULE` | On a recurring schedule defined by `triggerConfig` (e.g. cron expression). |
|
||||
|
||||
<Callout
|
||||
title="Trigger"
|
||||
variant="idea">
|
||||
When creating a workflow, you can not change the trigger event after the workflow has been created. Make sure to choose the correct event name that will trigger the workflow.
|
||||
</Callout>
|
||||
When you create a workflow via the API, the trigger defaults to `EVENT` with the event name you provide. To use `MANUAL` or `SCHEDULE`, change `triggerType` and `triggerConfig` with `PATCH /workflows/:id` after creation.
|
||||
|
||||
### Defining workflow steps
|
||||
Workflows consists of multiple steps that define the sequence of actions to be taken.
|
||||
A workflow's trigger event name **cannot be changed after the first execution** — pick it carefully. Other trigger configuration (delays, conditions inside steps) remains editable.
|
||||
|
||||
| Step Type | Description |
|
||||
|-----------|-------------|
|
||||
| Send Email | Sends an email to the contact using a specified template. You can customize the email content using variables from the event data. |
|
||||
| Delay | Pauses the workflow for a specified duration before proceeding to the next step. |
|
||||
| Wait for Event | Pauses the workflow until a specified event is received for the contact. You can also set a timeout duration to proceed if the event is not received within that time. |
|
||||
| Condition | Evaluates a condition based on the event data or contact data and branches the workflow accordingly. |
|
||||
| Webhook | Sends a webhook to a specified URL with the event and contact data. |
|
||||
| Update Contact | Updates the contact's data with specified key-value pairs. |
|
||||
| Exit | Terminates the workflow for the contact. |
|
||||
## Lifecycle and the `enabled` flag
|
||||
|
||||
## Managing workflow executions
|
||||
You can monitor and manage contacts going through workflows in the executions tab of the workflow detail page. Here you can see the status of each execution, cancel a specific execution or all executions.
|
||||
Workflows are created with `enabled: false`. **No executions start until you flip `enabled` to `true`** — this is the most common reason "my workflow isn't firing."
|
||||
|
||||
A workflow will be locked while there are active executions. If you want to make changes to a running workflow you will either need to pause it and wait for all executions to complete, or cancel all active executions.
|
||||
The `allowReentry` flag (default `false`) controls whether a contact can enter the same workflow more than once. Leave it off for "first-touch only" sequences (welcome emails, onboarding) and turn it on for re-engageable journeys (re-prompts, repeated nudges).
|
||||
|
||||
## Step types
|
||||
|
||||
A workflow always begins with a single auto-created `TRIGGER` step. You build the rest of the graph by adding steps and **transitions** (edges) between them.
|
||||
|
||||
| Step type | Purpose | Required config |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `TRIGGER` | Auto-created entry point. Holds the trigger configuration. | `eventName` (for `EVENT` trigger) |
|
||||
| `SEND_EMAIL` | Sends an email to the contact using a template. Template variables resolve from contact data + execution context. | `templateId`, optional `from` override |
|
||||
| `DELAY` | Pauses the execution for a fixed duration before continuing. | `amount`, `unit` (`minutes` / `hours` / `days`) |
|
||||
| `WAIT_FOR_EVENT` | Pauses until a specified event is tracked on the contact, with a timeout fallback. | `eventName`, `timeout` (seconds) |
|
||||
| `CONDITION` | Branches the execution based on contact data or event data. Each `CONDITION` step has two outgoing transitions tagged `yes` / `no`. | A filter expression (same shape as segment filters) |
|
||||
| `WEBHOOK` | Calls an external HTTPS endpoint with contact + execution context as the JSON body. | `url`, optional `method`, `headers` |
|
||||
| `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object |
|
||||
| `EXIT` | Terminates the execution. Optionally records an `exitReason` for analytics. | optional `reason` |
|
||||
|
||||
## Transitions
|
||||
|
||||
Transitions are the edges of the workflow graph. For most step types a transition is a simple "next" pointer. For `CONDITION` steps, each transition carries a `branch` discriminator (`"yes"` or `"no"`) so the engine knows which path to take when the condition evaluates.
|
||||
|
||||
When deleting a step in the middle of a chain, pass `?splice=true` on `DELETE /workflows/:id/steps/:stepId` to automatically reconnect the surrounding transitions; otherwise the deletion leaves the graph disconnected.
|
||||
|
||||
## Executions
|
||||
|
||||
Each contact entering the workflow creates a `WorkflowExecution`. Executions move through these states:
|
||||
|
||||
| Status | Meaning |
|
||||
| ----------- | --------------------------------------------------------------------------------------------- |
|
||||
| `RUNNING` | Currently processing a step (or about to). |
|
||||
| `WAITING` | Paused inside a `DELAY` or `WAIT_FOR_EVENT` step. |
|
||||
| `COMPLETED` | Reached the end of the graph successfully. |
|
||||
| `EXITED` | Hit an `EXIT` step. `exitReason` records why. |
|
||||
| `FAILED` | An unrecoverable error occurred (e.g. webhook returned non-2xx after retries, template not found). |
|
||||
| `CANCELLED` | Cancelled manually via the API or dashboard. |
|
||||
|
||||
Each execution carries a `context` JSON object that's merged with the contact's `data` when rendering templates and evaluating conditions. When you start an execution manually with `POST /workflows/:id/executions`, you can pass an initial `context` — this is how you parameterise per-execution variables (a coupon code, a referrer name) without storing them on the contact.
|
||||
|
||||
## Locking active workflows
|
||||
|
||||
A workflow is **locked** for structural edits while it has active (`RUNNING` or `WAITING`) executions. To make breaking changes — adding/removing steps, changing the trigger event, switching trigger type — first either:
|
||||
|
||||
- Disable the workflow and wait for active executions to complete, or
|
||||
- `POST /workflows/:id/executions/cancel-all` to cancel everything in flight.
|
||||
|
||||
Cosmetic changes (renaming, editing template content referenced by steps) don't require this.
|
||||
|
||||
## Common patterns
|
||||
|
||||
- **Welcome series** — `EVENT` trigger on `signed_up`, send welcome email, delay 2 days, send tips email, delay 5 days, send upgrade nudge.
|
||||
- **Inbound auto-reply** — `EVENT` trigger on `email.received`, `CONDITION` on `event.spamVerdict == "PASS"`, then `SEND_EMAIL` with an auto-reply template. See [Receiving emails](/guides/receiving-emails).
|
||||
- **Re-engagement** — Triggered by a segment exit (`segment.active-users.exit`), send a "we miss you" email, wait 7 days for any `email.opened` event, branch on whether the contact engaged.
|
||||
- **Webhook fan-out** — `EVENT` trigger on `purchase.completed`, `WEBHOOK` step to your CRM, `UPDATE_CONTACT` to tag `{ tier: "customer" }`, `SEND_EMAIL` with the receipt.
|
||||
|
||||
## API reference
|
||||
|
||||
Workflows are managed through the `/workflows` API. See the [API overview](/api-reference/overview#workflows) for the full route list — the API exposes endpoints for the workflow itself, its steps, its transitions, and its executions.
|
||||
|
||||
## What's next
|
||||
|
||||
<Cards>
|
||||
<Card title="Templates" href="/concepts/templates">
|
||||
Author the email content used by `SEND_EMAIL` steps.
|
||||
</Card>
|
||||
<Card title="Receiving emails" href="/guides/receiving-emails">
|
||||
Trigger workflows from inbound mail with `email.received`.
|
||||
</Card>
|
||||
<Card title="Webhooks" href="/guides/webhooks">
|
||||
Use `WEBHOOK` steps to call your own services from a workflow.
|
||||
</Card>
|
||||
<Card title="Segments" href="/concepts/segments">
|
||||
Trigger workflows from segment entry / exit events.
|
||||
</Card>
|
||||
</Cards>
|
||||
|
||||
@@ -1,19 +1,121 @@
|
||||
---
|
||||
title: API Keys
|
||||
description: Manage your API keys and understand their usage
|
||||
description: How Plunk's two-key model works and how to use, rotate, and revoke keys safely
|
||||
icon: Key
|
||||
---
|
||||
|
||||
Each project has two unique API keys. A public key and a secret key. These keys are used to authenticate requests made to Plunk's API.
|
||||
Every project in Plunk has exactly two API keys: a **public** key and a **secret** key. Both authenticate requests to the same API but cover different surfaces.
|
||||
|
||||
## Public Key
|
||||
The public API key can only be used with the [/v1/track](/api-reference/public-api/trackEvent) endpoint to track events. This key can be safely exposed in client-side applications.
|
||||
## Where to find your keys
|
||||
|
||||
## Secret Key
|
||||
The secret API key can be used with all other endpoints in Plunk's API. This key should be kept confidential and not exposed in client-side applications.
|
||||
In the dashboard, open your project and go to **Settings → API Keys**. Both keys are visible — copy them and store them in environment variables (never commit them to source control).
|
||||
|
||||
If this key is compromised, a malicious actor could read and modify your project data, send emails, and perform other actions on your behalf.
|
||||
## The two keys
|
||||
|
||||
## Regenerating API Keys
|
||||
If you believe your API keys have been compromised, you can regenerate them in the project settings.
|
||||
Keep in mind that regenerating an API key will invalidate both keys, so make sure to update your applications with the new key.
|
||||
| Key | Prefix | Endpoints it can call | Where it's safe to use |
|
||||
| ---------- | ------ | ---------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| Public | `pk_` | `POST /v1/track` only | Client-side code (browser, mobile apps) |
|
||||
| Secret | `sk_` | Every other endpoint — sending email, contacts, segments, campaigns, templates, workflows, domains, billing | Server-side only |
|
||||
|
||||
The public key is intentionally limited so you can call `/v1/track` from a browser or mobile app to record events without exposing your project's full API surface. Anything beyond event tracking — sending emails, reading contacts, creating campaigns — requires the secret key.
|
||||
|
||||
The project a request belongs to is derived from the key automatically. There's no separate project ID parameter on API requests.
|
||||
|
||||
## Authenticating requests
|
||||
|
||||
Both keys use the same `Authorization: Bearer` format. Pass the key in the `Authorization` header on every request.
|
||||
|
||||
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<Tabs items={['cURL', 'JavaScript', 'Python']}>
|
||||
|
||||
<Tab value="cURL">
|
||||
|
||||
```bash
|
||||
curl https://next-api.useplunk.com/v1/send \
|
||||
-H "Authorization: Bearer $PLUNK_SECRET_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "to": "user@example.com", "subject": "Hello", "body": "<p>Hi</p>" }'
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="JavaScript">
|
||||
|
||||
```javascript
|
||||
const response = await fetch('https://next-api.useplunk.com/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: 'user@example.com',
|
||||
subject: 'Hello',
|
||||
body: '<p>Hi</p>',
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="Python">
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
'https://next-api.useplunk.com/v1/send',
|
||||
headers={
|
||||
'Authorization': f"Bearer {os.environ['PLUNK_SECRET_KEY']}",
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json={
|
||||
'to': 'user@example.com',
|
||||
'subject': 'Hello',
|
||||
'body': '<p>Hi</p>',
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
If the key prefix doesn't match the endpoint (e.g. you use `pk_` on `/v1/send`), the API returns `401` with code `INVALID_API_KEY`.
|
||||
|
||||
## Rotating keys
|
||||
|
||||
Both keys live as a single pair. Rotating regenerates **both** keys simultaneously — there is no way to rotate one without invalidating the other.
|
||||
|
||||
When to rotate:
|
||||
|
||||
- A key has been committed to a public repository or shared with someone who shouldn't have it.
|
||||
- You're offboarding a contractor or revoking a deployment's access.
|
||||
- You're following a periodic rotation policy (e.g. every 90 days).
|
||||
|
||||
To rotate:
|
||||
|
||||
1. Open **Settings → API Keys** in the dashboard, or call `POST /users/@me/projects/:id/regenerate-keys`.
|
||||
2. Confirm the rotation. The old keys stop working immediately.
|
||||
3. Update every consumer (your backend env vars, client-side bundles, third-party integrations).
|
||||
|
||||
There's no grace period — plan a brief deployment window if you have multiple consumers.
|
||||
|
||||
## Storage best practices
|
||||
|
||||
- **Server keys (`sk_`)** belong in environment variables on the server (`.env.local` for development, your platform's secret store for production). Never bundle them into a frontend build.
|
||||
- **Client keys (`pk_`)** can be shipped in browser code, but treat them as semi-sensitive — rotate if a malicious actor abuses your tracking endpoint.
|
||||
- Use separate Plunk projects for staging and production so a leaked staging key can't touch production data.
|
||||
|
||||
## If a key is compromised
|
||||
|
||||
1. Rotate immediately (above).
|
||||
2. Audit the **Activity** tab for unexpected sends, contact mutations, or campaign changes during the window the key was leaked.
|
||||
3. Check **Billing → Consumption** for usage spikes that suggest the key was abused.
|
||||
4. If you find unauthorized activity, contact support with the request IDs from the suspicious entries.
|
||||
|
||||
## API reference
|
||||
|
||||
- `POST /users/@me/projects/:id/regenerate-keys` — regenerate both keys for a project. The response includes the new keys.
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: Custom fields
|
||||
description: Store arbitrary data on your contacts and use it for personalization, segmentation, and workflows
|
||||
icon: Tags
|
||||
---
|
||||
|
||||
Every contact in Plunk has a `data` object alongside the built-in fields (`email`, `subscribed`, `createdAt`, `updatedAt`). You decide what goes in `data` — first names, plan tiers, signup dates, internal user IDs, anything you want to use later in templates, segments, or workflow conditions.
|
||||
|
||||
This guide covers how custom fields work end to end: setting them, the rules around types, using them, and cleaning them up.
|
||||
|
||||
## Setting custom fields
|
||||
|
||||
Set custom fields any time you create or update a contact:
|
||||
|
||||
```bash
|
||||
# Via /v1/track (auto-creates the contact)
|
||||
curl -X POST {{API_URL}}/v1/track \
|
||||
-H "Authorization: Bearer sk_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "ada@example.com",
|
||||
"event": "signed_up",
|
||||
"data": {
|
||||
"firstName": "Ada",
|
||||
"plan": "pro",
|
||||
"signupDate": "2026-05-06T12:00:00Z",
|
||||
"lifetimeValue": 240
|
||||
}
|
||||
}'
|
||||
|
||||
# Via PATCH /contacts/:id
|
||||
curl -X PATCH {{API_URL}}/contacts/cnt_abc123 \
|
||||
-H "Authorization: Bearer sk_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "data": { "plan": "enterprise" } }'
|
||||
```
|
||||
|
||||
`data` is patched — only the keys you send change. Other keys keep their existing values.
|
||||
|
||||
### Special values
|
||||
|
||||
| Value | Behaviour |
|
||||
| -------------- | ------------------------------------------------------------------------------- |
|
||||
| Primitive | Stored. Available for templates, segments, workflows. |
|
||||
| `null` | Deletes the key from the contact. |
|
||||
| `""` (empty) | Ignored — does not overwrite existing data. |
|
||||
| `{ value, persistent: false }` | Used for this send only; not stored on the contact. Good for one-shot codes (password resets, magic links). |
|
||||
|
||||
## Type inference
|
||||
|
||||
Plunk infers a type for each field the first time it sees a non-null value in your project:
|
||||
|
||||
| Detected as | Examples |
|
||||
| ----------- | -------------------------------------------------------------- |
|
||||
| Number | `42`, `3.14` |
|
||||
| Boolean | `true`, `false` |
|
||||
| Date | Strings matching `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SS[.sss]Z` |
|
||||
| String | Anything else |
|
||||
|
||||
The inferred type powers the segment filter UI (showing date pickers for date fields, number inputs for numeric ones, etc.) and is sticky once set per project. If you mix types for a key, the original inference wins for the UI but Plunk still stores whatever value you send.
|
||||
|
||||
To use date-aware operators (`within`, `olderThan`) on a custom field, store the value as a full ISO 8601 string — `"2026-05-06T12:00:00Z"` works, `"05/06/2026"` doesn't.
|
||||
|
||||
## Reserved keys
|
||||
|
||||
Some keys are managed by Plunk and **silently filtered out** of any `data` payload — sending them won't store them and won't return an error:
|
||||
|
||||
`id`, `plunk_id`, `plunk_email`, `email`, `unsubscribeUrl`, `subscribeUrl`, `manageUrl`
|
||||
|
||||
Plus these core contact fields, which exist outside `data`: `email`, `subscribed`, `createdAt`, `updatedAt`. To change `email` or `subscribed`, use the dedicated request fields, not `data`.
|
||||
|
||||
`locale` is a special soft-reserved key — it's stored under `data` like other fields, but Plunk reads it for [localization](/guides/localization).
|
||||
|
||||
## Using custom fields
|
||||
|
||||
### In templates
|
||||
|
||||
Reference any field by name as a Handlebars variable:
|
||||
|
||||
```html
|
||||
<p>Hi {{firstName ?? "there"}},</p>
|
||||
<p>You've been on the {{plan}} plan since {{signupDate}}.</p>
|
||||
```
|
||||
|
||||
The `?? fallback` syntax is Plunk-specific and lets you provide a default when the field is missing or null. Nested data (`data.profile.tier`) is accessible the same way: `{{profile.tier}}`.
|
||||
|
||||
### In segments
|
||||
|
||||
Reference custom fields with the `data.` prefix in segment filters:
|
||||
|
||||
```json
|
||||
{ "field": "data.plan", "operator": "equals", "value": "pro" }
|
||||
{ "field": "data.lifetimeValue", "operator": "greaterThan", "value": 100 }
|
||||
{ "field": "data.signupDate", "operator": "within", "value": 30, "unit": "days" }
|
||||
```
|
||||
|
||||
See the [Segments concept page](/concepts/segments) for the full filter taxonomy.
|
||||
|
||||
### In workflows
|
||||
|
||||
`CONDITION` steps inside workflows use the same `data.` notation. The `UPDATE_CONTACT` step lets you write to custom fields as a workflow progresses (e.g. tag `{ stage: "activated" }` after the welcome email is opened).
|
||||
|
||||
## Inspecting your custom fields
|
||||
|
||||
`GET /contacts/fields` returns every field — standard plus custom — that's been used in your project, with the inferred type and the share of contacts that have it set:
|
||||
|
||||
```json
|
||||
{
|
||||
"fields": [
|
||||
{ "field": "email", "type": "string", "isCustom": false, "coverage": 1.0 },
|
||||
{ "field": "data.plan", "type": "string", "isCustom": true, "coverage": 0.62 },
|
||||
{ "field": "data.signupDate", "type": "date", "isCustom": true, "coverage": 0.95 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use this to audit your custom fields, find stale ones, and feed dropdowns in your own UI.
|
||||
|
||||
For a single field, `GET /contacts/fields/:field/values` returns the distinct values seen for that field — useful for showing a "Filter by plan" dropdown in your dashboard.
|
||||
|
||||
## Cleaning up unused fields
|
||||
|
||||
Custom fields tend to accumulate. Two endpoints help:
|
||||
|
||||
- `GET /contacts/fields/:field/usage` — lists every segment, campaign, and workflow that references the field. Run this before deleting to make sure you don't break anything.
|
||||
- `DELETE /contacts/fields/:field` — removes the field from every contact in the project.
|
||||
|
||||
Deleting only affects contact data — segments and workflows that referenced the field continue to exist but their conditions on that field will stop matching anything. The usage endpoint helps you find and clean those up first.
|
||||
|
||||
## Best practices
|
||||
|
||||
- **Pick stable field names.** Renaming a field means updating every segment, template, and workflow that references it.
|
||||
- **Use ISO 8601 for dates.** Otherwise date-aware segment operators won't work.
|
||||
- **Don't put secrets in `data`.** Custom fields are visible in the dashboard and to anyone with API access. Use `{ value, persistent: false }` for one-shot codes that shouldn't persist.
|
||||
- **Prefer flat keys for high-cardinality fields.** Nested paths (`data.profile.tier`) work but are slightly harder to discover in the segment UI.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
title: Importing contacts from CSV
|
||||
description: Bulk-load contacts and their custom fields from a CSV file
|
||||
icon: Upload
|
||||
---
|
||||
|
||||
Plunk supports bulk-importing contacts from a CSV file — useful for migrating from another platform, loading an initial list, or syncing a large batch of contacts that you can't reasonably stream through `/v1/track`.
|
||||
|
||||
## CSV format
|
||||
|
||||
The CSV must have a header row. The only required column is `email`. Every other column becomes a custom field on the contact under `data.<columnName>`.
|
||||
|
||||
A minimal file:
|
||||
|
||||
```csv
|
||||
email
|
||||
ada@example.com
|
||||
grace@example.com
|
||||
linus@example.com
|
||||
```
|
||||
|
||||
A richer file with custom fields:
|
||||
|
||||
```csv
|
||||
email,firstName,plan,signupDate
|
||||
ada@example.com,Ada,pro,2026-01-15T00:00:00Z
|
||||
grace@example.com,Grace,enterprise,2025-11-02T00:00:00Z
|
||||
linus@example.com,Linus,free,2026-04-21T00:00:00Z
|
||||
```
|
||||
|
||||
In this example, every imported contact ends up with `data.firstName`, `data.plan`, and `data.signupDate` set.
|
||||
|
||||
### Rules and limits
|
||||
|
||||
- **File size**: up to 5 MB per upload. For larger lists, split the file and run multiple imports.
|
||||
- **Encoding**: UTF-8. Non-UTF-8 files may produce garbled custom field values.
|
||||
- **Email column**: must be present and valid. Rows with missing or invalid emails are reported back as errors.
|
||||
- **Reserved column names**: `id`, `subscribed`, `createdAt`, `updatedAt`, and the auto-generated URL variables (`unsubscribeUrl`, etc.) are silently filtered out. Don't include them as columns.
|
||||
- **Date columns**: use ISO 8601 (`2026-05-06T12:00:00Z`) so they're typed as dates and become usable with `within` / `olderThan` segment operators.
|
||||
- **Existing contacts**: if a row's email matches an existing contact, the import **updates** the contact (merging the CSV's columns into `data`). It doesn't create a duplicate or overwrite the whole record.
|
||||
|
||||
## Importing your CSV
|
||||
|
||||
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
|
||||
|
||||
<Tabs items={['Dashboard', 'API']}>
|
||||
|
||||
<Tab value="Dashboard">
|
||||
|
||||
1. Open **Contacts** and click **Import**.
|
||||
2. Pick your CSV file. Plunk validates the header and shows a preview of the first few rows.
|
||||
3. Confirm. The import is queued and runs in the background.
|
||||
4. The Imports page shows progress and any per-row errors when complete.
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab value="API">
|
||||
|
||||
For automation, use the import API:
|
||||
|
||||
**Step 1 — upload the CSV**
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/contacts/import \
|
||||
-H "Authorization: Bearer sk_..." \
|
||||
-F "file=@contacts.csv"
|
||||
```
|
||||
|
||||
The response includes a `jobId`:
|
||||
|
||||
```json
|
||||
{ "jobId": "imp_abc123", "status": "queued" }
|
||||
```
|
||||
|
||||
**Step 2 — poll for status**
|
||||
|
||||
```bash
|
||||
curl {{API_URL}}/contacts/import/imp_abc123 \
|
||||
-H "Authorization: Bearer sk_..."
|
||||
```
|
||||
|
||||
Poll every few seconds until `status` is `completed` or `failed`. The response includes counts:
|
||||
|
||||
```json
|
||||
{
|
||||
"jobId": "imp_abc123",
|
||||
"status": "completed",
|
||||
"totalRows": 12000,
|
||||
"imported": 11985,
|
||||
"updated": 8,
|
||||
"skipped": 0,
|
||||
"errors": [
|
||||
{ "row": 47, "email": "bad@", "reason": "invalid_email" },
|
||||
{ "row": 1042, "email": "@example", "reason": "invalid_email" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`imported` counts new contacts created. `updated` counts existing contacts whose `data` was patched. `errors` lists per-row issues with row number and reason so you can fix and re-upload.
|
||||
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Tips
|
||||
|
||||
- **Unsubscribed contacts**: imported contacts are created subscribed by default. If your CSV represents users who never opted in, set up a workflow that filters out anyone you shouldn't email — or import them and then bulk-unsubscribe them with `POST /contacts/bulk-unsubscribe` to keep them on file but unmailed.
|
||||
- **Custom fields appear in segments immediately**: as soon as the import finishes, you can build dynamic segments on any of the imported columns.
|
||||
- **Idempotent reruns**: re-running the same CSV updates contacts in place rather than creating duplicates. If you fix a column and re-upload, all matching rows get the corrected value.
|
||||
|
||||
## Related
|
||||
|
||||
- [Custom fields](/guides/custom-fields) — how the `data.<column>` fields you import are typed and used.
|
||||
- [Bulk operations](/api-reference/overview#contacts) — `bulk-subscribe`, `bulk-unsubscribe`, `bulk-delete` for mass actions on existing contacts (max 1,000 per call).
|
||||
@@ -8,24 +8,32 @@ Plunk automatically monitors the bounce and complaint rates of your emails to he
|
||||
|
||||
## Bounce Management
|
||||
|
||||
A bounce occurs when an email cannot be delivered to the recipient's inbox. When an email bounces, Plunk will automatically unsubscribe the contact and also send an event on the contact for `email.bounced`.
|
||||
A bounce occurs when an email cannot be delivered to the recipient's inbox. Plunk records every bounce as an `email.bounce` event on the contact, and automatically unsubscribes the contact **only on permanent (hard) bounces**. Transient (soft) bounces don't change subscription state — they're typically retried by the upstream mail server.
|
||||
|
||||
### Types of Bounces
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| Hard Bounce | Permanent delivery failure (e.g., invalid email address) |
|
||||
| Soft Bounce | Temporary delivery failure (e.g., mailbox full) |
|
||||
|
||||
| Type | Description | Auto-unsubscribes? |
|
||||
|------|-------------|---------------------|
|
||||
| **Permanent (hard)** | Permanent delivery failure — invalid address, blocked domain, recipient rejected. | Yes |
|
||||
| **Transient (soft)** | Temporary delivery failure — mailbox full, server unavailable, greylisted. | No |
|
||||
| **Undetermined** | The upstream provider couldn't classify the bounce. | No |
|
||||
|
||||
Both bounce types fire an `email.bounce` event you can branch on inside workflows or webhooks (use the event payload's `bounceType` field to distinguish them).
|
||||
|
||||
### Preventing bounces
|
||||
- [Verify email addresses](/api-reference/public-api/verifyEmail)
|
||||
- Regularly clean your email list
|
||||
- Use double opt-in for subscriptions
|
||||
|
||||
- [Verify email addresses](/api-reference/public-api/verifyEmail) at signup before adding them to your list.
|
||||
- Regularly clean your email list — segment unengaged contacts and re-confirm or remove them.
|
||||
- Build a confirmation flow with a workflow that sends a verification email and only marks the contact as confirmed when they click through.
|
||||
|
||||
## Complaint Management
|
||||
|
||||
A complaint occurs when a recipient marks your email as spam. When a complaint is received, Plunk will automatically unsubscribe the contact and send an event on the contact for `email.complaint`.
|
||||
A complaint occurs when a recipient marks your email as spam in their inbox provider. When Plunk receives that complaint, the contact is **always automatically unsubscribed** and Plunk fires an `email.complaint` event on the contact.
|
||||
|
||||
Complaints are taken more seriously than bounces by mail providers — even a small complaint rate can hurt your sender reputation and deliverability.
|
||||
|
||||
### Preventing complaints
|
||||
- Ensure your emails are relevant and valuable to your audience
|
||||
- Include a clear unsubscribe link in every email
|
||||
- Monitor your email frequency to avoid overwhelming your contacts
|
||||
- Ensure your emails are relevant and valuable to your audience.
|
||||
- Include a clear, working unsubscribe link in every marketing email — Plunk injects this automatically for `MARKETING` template/campaign types.
|
||||
- Monitor your email frequency to avoid overwhelming your contacts.
|
||||
- Make sure recipients clearly opted in. Avoid scraped, purchased, or stale lists.
|
||||
@@ -1,15 +1,66 @@
|
||||
---
|
||||
title: Localization
|
||||
description: Support for multiple languages and regions
|
||||
description: Translate the unsubscribe footer and contact-facing pages into your contacts' languages
|
||||
icon: Globe
|
||||
---
|
||||
|
||||
Localization in Plunk allows you to configure the language of the unsubscribe footer and contact-facing pages (subscribe, unsubscribe, manage) to better suit your audience.
|
||||
Plunk localizes the strings it controls — the auto-injected unsubscribe footer on marketing emails, the hosted unsubscribe / subscribe / preferences pages — into the language of your audience. Your own template content (subject lines, body copy) is **not** translated automatically; you control that yourself.
|
||||
|
||||
## Configuring Localization
|
||||
You can set the default language for your project in the project settings.
|
||||
## Setting the project default
|
||||
|
||||
## Overriding language per contact
|
||||
You can override the default language for individual contacts by setting the `locale` field in the contact data. This field should contain a valid [ISO 639](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) language code (e.g., 'en' for English, 'fr' for French, 'es' for Spanish).
|
||||
Open **Settings → Project** and pick a default language. This is used for any contact who doesn't have a specific language set on their record.
|
||||
|
||||
When sending emails, Plunk will use the contact's specified language if available; otherwise, it will fall back to the project's default language.
|
||||
## Overriding per contact
|
||||
|
||||
Set a `locale` field on the contact's `data` to override the project default for that recipient:
|
||||
|
||||
```bash
|
||||
curl -X POST {{API_URL}}/v1/track \
|
||||
-H "Authorization: Bearer sk_..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "ada@example.com",
|
||||
"event": "signed_up",
|
||||
"data": { "locale": "fr" }
|
||||
}'
|
||||
```
|
||||
|
||||
Use a [BCP 47](https://en.wikipedia.org/wiki/IETF_language_tag) / ISO 639 code that matches one of the supported languages below. You can also reference `{{locale}}` inside template variables to branch your own copy on the contact's language.
|
||||
|
||||
If the contact's `locale` doesn't match a supported language, Plunk falls back to the project default. If neither is set, English is used.
|
||||
|
||||
## Supported languages
|
||||
|
||||
| Code | Language |
|
||||
| -------- | -------------------------------------- |
|
||||
| `en` | English |
|
||||
| `nl` | Dutch (Nederlands) |
|
||||
| `fr` | French (Français) |
|
||||
| `de` | German (Deutsch) |
|
||||
| `es` | Spanish (Español) |
|
||||
| `it` | Italian (Italiano) |
|
||||
| `pt` | Portuguese (Português) |
|
||||
| `pl` | Polish (Polski) |
|
||||
| `cs` | Czech (Čeština) |
|
||||
| `bg` | Bulgarian (Български) |
|
||||
| `hi` | Hindi (हिंदी) |
|
||||
| `zh-CN` | Chinese (Simplified, China) |
|
||||
| `zh-TW` | Chinese (Traditional, Taiwan) |
|
||||
| `zh-HK` | Chinese (Traditional, Hong Kong) |
|
||||
|
||||
## What gets translated
|
||||
|
||||
| Surface | Translated? |
|
||||
| ---------------------------------------- | ----------- |
|
||||
| Unsubscribe footer on marketing emails | Yes |
|
||||
| Hosted unsubscribe / subscribe / preferences pages | Yes |
|
||||
| **Your own template subject and body** | No — author per-language templates and branch on `{{locale}}` |
|
||||
|
||||
## Translating your own templates
|
||||
|
||||
Plunk doesn't translate the content of your templates. Two common patterns:
|
||||
|
||||
- **One template per language**, picked in your code or workflow before sending. Name them with the locale suffix (`welcome-en`, `welcome-fr`).
|
||||
- **One template with branched copy**, using `{{locale}}` inside Handlebars conditionals to inline different language variants. Workable for short copy; gets unwieldy for long emails.
|
||||
|
||||
For larger localization needs, the per-language template approach is easier to maintain.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
{
|
||||
"pages": ["list-hygiene", "verifying-domains", "receiving-emails", "tracking", "api-keys", "localization", "webhooks"]
|
||||
"pages": [
|
||||
"list-hygiene",
|
||||
"verifying-domains",
|
||||
"receiving-emails",
|
||||
"tracking",
|
||||
"api-keys",
|
||||
"localization",
|
||||
"webhooks",
|
||||
"importing-contacts",
|
||||
"custom-fields",
|
||||
"segment-filters",
|
||||
"unsubscribe-pages"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,110 +1,145 @@
|
||||
---
|
||||
title: Receiving emails
|
||||
description: Receive incoming emails at your verified domain and trigger automated workflows
|
||||
description: Receive incoming email at your verified domain and turn it into events you can drive workflows from
|
||||
icon: Inbox
|
||||
---
|
||||
|
||||
Plunk can receive emails sent to your verified domain and turn them into `email.received` events. This allows you to build automated workflows that respond to inbound emails, such as support ticket systems, auto-responders, or email-based integrations.
|
||||
Plunk can receive emails sent to any address at your verified domain, store them in your project, and emit an `email.received` event you can drive workflows from. This unlocks auto-replies, ticketing, conditional forwarding, and any other "do something when an email arrives" pattern.
|
||||
|
||||
## How it works
|
||||
## What happens when an email arrives
|
||||
|
||||
When someone sends an email to your verified domain (e.g., `support@yourdomain.com` or `hello@yourdomain.com`), Plunk will:
|
||||
When someone emails an address at your verified domain, Plunk:
|
||||
|
||||
1. Receive the email through AWS SES
|
||||
2. Automatically create or update a contact for the sender
|
||||
3. Trigger an `email.received` event that can start workflows
|
||||
4. Make the email metadata available to your workflow steps
|
||||
1. Parses the message and stores it as an inbound `Email` record visible in your project's Activity feed.
|
||||
2. Creates the sender as a contact in your project (or updates them if they already exist), subscribed by default.
|
||||
3. Tracks an `email.received` event on the sender contact, which any workflow can listen to.
|
||||
|
||||
The sender is automatically added to your contacts as a subscribed contact, allowing you to respond using Plunk's email sending capabilities.
|
||||
The HTML body is sanitized before being stored — scripts, iframes, event handlers, and `javascript:` URIs are stripped. Plain text is preserved as-is when no HTML part is available.
|
||||
|
||||
## Setting up inbound email
|
||||
## Setup
|
||||
|
||||
<div className='fd-steps [&_h3]:fd-step'>
|
||||
import {Step, Steps} from 'fumadocs-ui/components/steps';
|
||||
|
||||
### Verify your domain
|
||||
<Steps>
|
||||
|
||||
Before you can receive emails, your domain must be verified in Plunk. Follow the [verifying domains](/guides/verifying-domains) guide to set up the required DKIM, SPF, and MX records for sending.
|
||||
<Step>
|
||||
|
||||
### Verify your domain for sending
|
||||
|
||||
Inbound is only enabled on domains that are fully verified for sending (DKIM + SPF + the bounce-feedback MX). Follow the [Verifying domains](/guides/verifying-domains) guide first.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Add the inbound MX record
|
||||
|
||||
In your project settings, navigate to the **Domains** tab and expand your verified domain. You'll see an optional "Inbound Email" section with an MX record configuration:
|
||||
Open your project's **Domains** tab, expand the verified domain, and look for the **Inbound Email** section. Plunk shows you the exact MX record value to add to your DNS — copy it from there and add it as an MX record on your domain.
|
||||
|
||||
- **Type**: MX
|
||||
- **Name**: `yourdomain.com` (your root domain)
|
||||
- **Value**: `10 inbound-smtp.eu-north-1.amazonaws.com`
|
||||
|
||||
Add this MX record to your domain's DNS settings. The priority value `10` ensures that inbound emails are routed to AWS SES for processing.
|
||||
|
||||
<Callout title="Multiple MX records" variant="warning">
|
||||
If you're already using MX records for another email service (like Google Workspace or Microsoft 365), adding this MX
|
||||
record may conflict. You can only have one primary email receiver per domain. Consider using a subdomain (e.g.,
|
||||
`mail.yourdomain.com`) if you need to maintain both services.
|
||||
<Callout title="Conflict with existing email" type="warn">
|
||||
A domain can only have one primary inbound MX target. If you already use Google Workspace, Microsoft 365, or another provider for receiving email on the apex domain, your existing email will break if you switch the MX to point at Plunk. The usual fix is to receive Plunk inbound on a subdomain (e.g. `mail.yourdomain.com` or `support.yourdomain.com`) so you can keep your main mailbox on the existing provider. The subdomain still needs to be verified for sending in Plunk before its MX will be accepted.
|
||||
</Callout>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Wait for DNS propagation
|
||||
|
||||
DNS changes can take anywhere from a few minutes to 48 hours to fully propagate. You can verify the MX record is set correctly using:
|
||||
DNS changes take anywhere from a few minutes to 48 hours to propagate. You can verify the MX record is live:
|
||||
|
||||
```bash
|
||||
dig MX yourdomain.com
|
||||
```
|
||||
|
||||
You should see the AWS SES inbound endpoint in the response.
|
||||
You should see the value Plunk gave you in the response.
|
||||
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
## Creating workflows with email.received
|
||||
<Step>
|
||||
|
||||
Once your MX record is configured, you can create workflows that respond to incoming emails.
|
||||
### Send a test email
|
||||
|
||||
### Event trigger
|
||||
Send an email from any external account to any address at your domain (e.g. `anything@yourdomain.com`) and check that:
|
||||
|
||||
Create a new workflow and use `email.received` as the trigger event. This workflow will run every time an email is received at your domain.
|
||||
- A new `Email` row appears in the project's Activity feed with type **Inbound**.
|
||||
- The sender shows up as a contact in the **Contacts** tab.
|
||||
- An `email.received` event is recorded on that contact.
|
||||
|
||||
### Available event data
|
||||
</Step>
|
||||
|
||||
The `email.received` event includes the following data that you can use in your workflow steps:
|
||||
</Steps>
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------- |
|
||||
| `messageId` | string | Unique identifier for the received message |
|
||||
| `from` | string | Email address of the sender |
|
||||
| `fromHeader` | string | Full "From" header including display name |
|
||||
| `to` | string | Primary recipient email address |
|
||||
| `subject` | string | Email subject line |
|
||||
| `timestamp` | string | ISO 8601 timestamp when the email was received |
|
||||
| `recipients` | string[] | All recipient email addresses |
|
||||
| `hasContent` | boolean | Whether the email body was captured |
|
||||
| `body` | string | HTML body of the email (or plain text if no HTML) |
|
||||
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
|
||||
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
|
||||
| `spfVerdict` | string | SPF authentication result |
|
||||
| `dkimVerdict` | string | DKIM authentication result |
|
||||
| `dmarcVerdict` | string | DMARC authentication result |
|
||||
| `processingTimeMillis` | number | Time taken to process the email |
|
||||
## What gets stored
|
||||
|
||||
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
|
||||
Every accepted inbound email shows up in your project's Activity feed alongside outbound emails — you can search, filter, and inspect them the same way.
|
||||
|
||||
### Example: Auto-reply workflow
|
||||
Plunk stores: the parsed and sanitized HTML body (or plain text if no HTML is available), the headers surfaced in the event payload below, the authentication and spam verdicts, and the message ID.
|
||||
|
||||
Here's a simple workflow that sends an automatic reply when an email is received at `support@yourdomain.com`:
|
||||
**Plunk does not store**: the original raw message, attachments, threading headers (`In-Reply-To`, `References`), or headers beyond what's exposed on the event. If you need any of those, forward the email to your own service via a `WEBHOOK` step in the workflow that fires.
|
||||
|
||||
1. **Trigger**: `email.received`
|
||||
2. **Condition**: Check if `{{event.to}}` equals `support@yourdomain.com`
|
||||
3. **Send Email**:
|
||||
## The `email.received` event
|
||||
|
||||
The event is emitted on the sender contact (auto-created if it doesn't exist yet) and carries the full parsed message in its data field:
|
||||
|
||||
import {TypeTable} from 'fumadocs-ui/components/type-table';
|
||||
|
||||
<TypeTable
|
||||
type={{
|
||||
messageId: { type: 'string', description: 'Unique message identifier for the received email.' },
|
||||
from: { type: 'string', description: 'Sender email address (envelope / `From` header).' },
|
||||
fromHeader: { type: 'string', description: 'Full "From" header including display name, e.g. `"Ada <ada@example.com>"`.' },
|
||||
to: { type: 'string', description: 'Primary recipient at your verified domain.' },
|
||||
recipients: { type: 'array of strings', description: 'Every recipient address (covers `To`, `Cc`, and BCC envelope recipients).' },
|
||||
subject: { type: 'string', description: 'Subject line.' },
|
||||
timestamp: { type: 'string', description: 'ISO 8601 receive timestamp.' },
|
||||
hasContent: { type: 'boolean', description: '`true` when a body was successfully parsed.' },
|
||||
body: { type: 'string', description: 'Sanitized HTML body, or plain text if no HTML part. See sanitization rules above.' },
|
||||
spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
|
||||
virusVerdict: { type: 'string', description: 'Virus scan result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
|
||||
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
|
||||
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
|
||||
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
|
||||
processingTimeMillis: { type: 'number', description: 'How long it took to process the email.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
The event is **always** emitted — Plunk does not block emails based on the spam, virus, or authentication verdicts. Filter them yourself in the workflow that fires.
|
||||
|
||||
In templates and workflow steps, access these fields via the event variable namespace, e.g. `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
|
||||
|
||||
## Building workflows on inbound
|
||||
|
||||
### Auto-reply
|
||||
|
||||
A minimal auto-reply on `support@yourdomain.com`:
|
||||
|
||||
1. **Trigger**: `email.received`.
|
||||
2. **Condition**: continue only if `event.to` equals `support@yourdomain.com` and `event.spamVerdict == "PASS"` and `event.virusVerdict == "PASS"`.
|
||||
3. **Send email**:
|
||||
- **To**: `{{event.from}}`
|
||||
- **Subject**: `Re: {{event.subject}}`
|
||||
- **Body**: `Thank you for your message. We received: "{{event.body}}". We'll get back to you soon!`
|
||||
- **Body**: `Thanks for your message. We've received your email and will respond within one business day.`
|
||||
|
||||
This workflow reads the incoming email body and includes it in the auto-reply response.
|
||||
Use a `TRANSACTIONAL` template for the auto-reply so it bypasses subscription checks (the sender is auto-subscribed but you don't want to fail to reply if they later unsubscribe).
|
||||
|
||||
### Example: Forward to webhook
|
||||
### Routing by recipient
|
||||
|
||||
For more advanced processing (like ticket creation or AI analysis), you can forward the email content to your own API:
|
||||
Route different addresses to different downstream actions in a single workflow:
|
||||
|
||||
1. **Trigger**: `email.received`
|
||||
1. **Trigger**: `email.received`.
|
||||
2. **Condition**: branch on `event.to`:
|
||||
- `support@…` → ticketing webhook → auto-reply.
|
||||
- `sales@…` → CRM webhook → notify Slack.
|
||||
- `billing@…` → billing system webhook.
|
||||
|
||||
### Forward to your API
|
||||
|
||||
For richer processing (NLP classification, ticket creation, attachments not captured by Plunk), forward the email to your own backend:
|
||||
|
||||
1. **Trigger**: `email.received`.
|
||||
2. **Webhook**:
|
||||
- **URL**: `https://api.example.com/support/tickets`
|
||||
- **URL**: `https://api.example.com/inbound`
|
||||
- **Method**: `POST`
|
||||
- **Body**:
|
||||
```json
|
||||
@@ -112,59 +147,89 @@ For more advanced processing (like ticket creation or AI analysis), you can forw
|
||||
"from": "{{event.from}}",
|
||||
"subject": "{{event.subject}}",
|
||||
"body": "{{event.body}}",
|
||||
"timestamp": "{{event.timestamp}}"
|
||||
"messageId": "{{event.messageId}}",
|
||||
"timestamp": "{{event.timestamp}}",
|
||||
"verdicts": {
|
||||
"spam": "{{event.spamVerdict}}",
|
||||
"virus": "{{event.virusVerdict}}",
|
||||
"spf": "{{event.spfVerdict}}",
|
||||
"dkim": "{{event.dkimVerdict}}",
|
||||
"dmarc": "{{event.dmarcVerdict}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Your backend receives the full email content and can process it (create a ticket, run AI analysis, etc.).
|
||||
### Filter spam and virus before processing
|
||||
|
||||
Always include a `CONDITION` step early in the workflow that drops anything where `spamVerdict` or `virusVerdict` is `FAIL`. Plunk does not pre-filter for you.
|
||||
|
||||
## Multi-project domains
|
||||
|
||||
If you have verified the same domain in multiple projects, incoming emails will be processed for **all projects** that have the domain verified. Each project will:
|
||||
If the same domain is verified in multiple projects, every inbound email is delivered to **every project** that has it verified — each gets its own `Email` record, its own contact upsert, and its own `email.received` event. This is by design (it lets you split a single inbox across staging and production projects, or hand off the same inbound stream to multiple teams).
|
||||
|
||||
- Create/update the sender as a contact in that project
|
||||
- Trigger the `email.received` event in that project
|
||||
- Run any workflows configured for that event
|
||||
If you don't want this, only verify the domain in one project at a time.
|
||||
|
||||
This allows you to segment inbound email handling across different projects if needed.
|
||||
## Billing
|
||||
|
||||
## Limitations
|
||||
Inbound emails count toward your project's email usage at **1 credit per received email**, just like outbound. The free tier and paid tiers consume the same pool.
|
||||
|
||||
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `anything@yourdomain.com`). You can use workflow conditions to route emails based on the `to` field.
|
||||
- **Attachments**: Email attachments are not currently captured or stored.
|
||||
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
|
||||
You can set a per-project inbound cap under **Billing → Limits**. Once the cap is reached, **inbound emails are silently dropped for that project** until the cap resets — they aren't queued or replayed. Other projects sharing the same domain are unaffected by another project's cap.
|
||||
|
||||
## Security considerations
|
||||
|
||||
Plunk captures several security verdicts for each incoming email:
|
||||
- **Treat the body as untrusted user input.** Plunk sanitizes HTML to prevent the obvious script-injection paths, but the message can still contain phishing links, social-engineering content, and unicode lookalikes. Don't render the body verbatim in any UI you control without re-escaping it for that context.
|
||||
- **Authentication verdicts are advisory.** Plunk records SPF / DKIM / DMARC results on the event but does not enforce them. Build your own policy: dropping unauthenticated mail at the workflow's first `CONDITION` step is a sensible default for sensitive inboxes (billing, account changes).
|
||||
- **Senders are auto-subscribed.** Inbound senders enter your audience as subscribed contacts. If you don't want that, add an `UPDATE_CONTACT` step at the end of the inbound workflow to set `subscribed: false`.
|
||||
- **Reply-loop risk.** If your auto-reply sends back to a domain you also receive on (or to a list address), you can create an infinite loop. Add a `CONDITION` that drops messages where `event.from` matches your own domain.
|
||||
|
||||
- **SPF (Sender Policy Framework)**: Verifies the sender's mail server is authorized
|
||||
- **DKIM (DomainKeys Identified Mail)**: Validates the email hasn't been tampered with
|
||||
- **DMARC (Domain-based Message Authentication)**: Combines SPF and DKIM for additional validation
|
||||
- **Spam verdict**: AWS SES's spam detection result
|
||||
- **Virus verdict**: AWS SES's virus scanning result
|
||||
## Limitations
|
||||
|
||||
You can use these verdicts in workflow conditions to automatically filter or quarantine suspicious emails before processing them.
|
||||
- **Catch-all only**: Plunk routes anything sent to any address at your domain to the same handler. You filter on `event.to` inside the workflow.
|
||||
- **No attachments**: attachments are dropped during parsing. Forward to your own service if you need them.
|
||||
- **No raw MIME**: the original message is not retained.
|
||||
- **No threading**: Plunk doesn't group inbound messages into threads or correlate them with outbound replies.
|
||||
- **40 MB size cap**: messages larger than 40 MB are rejected before processing.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Emails not being received
|
||||
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
|
||||
|
||||
1. **Check DNS propagation**: Verify the MX record is correctly set using `dig MX yourdomain.com`
|
||||
2. **Verify domain**: Ensure your domain is fully verified in Plunk (all DKIM, SPF, and MX records for sending)
|
||||
3. **Check workflow**: Create a simple test workflow with just an `email.received` trigger and a webhook to verify events are being generated
|
||||
4. **Check sender**: Try sending from a different email provider as some may cache DNS records
|
||||
<Accordions type="single">
|
||||
|
||||
### Duplicate events
|
||||
<Accordion title="Emails are not being received">
|
||||
|
||||
If you have the same domain verified in multiple projects, you will receive duplicate `email.received` events (one per project). This is expected behavior. Use project-specific workflows to handle this.
|
||||
1. **DNS**: `dig MX yourdomain.com` — confirm the value Plunk gave you appears in the response.
|
||||
2. **Domain verification**: in the dashboard, confirm the domain is fully verified for sending. Inbound MX won't process anything on an unverified domain.
|
||||
3. **Workflow not firing**: build a test workflow with just an `email.received` trigger and a webhook to a service like webhook.site to see whether events are being emitted at all. If yes, the issue is in your workflow logic; if no, it's upstream.
|
||||
4. **Sender DNS cache**: some senders cache MX lookups for hours. Test from a different email provider.
|
||||
|
||||
### Security verdicts failing
|
||||
</Accordion>
|
||||
|
||||
If incoming emails consistently show failing security verdicts:
|
||||
<Accordion title="Email arrives in Activity but no workflow runs">
|
||||
|
||||
- **SPF failures**: The sender's domain may not have SPF configured correctly
|
||||
- **DKIM failures**: The sender's domain may not have DKIM configured, or the email was forwarded/modified in transit
|
||||
- **DMARC failures**: The sender fails both SPF and DKIM checks
|
||||
- Check the workflow trigger event name is exactly `email.received`.
|
||||
- Check the workflow is **enabled** — workflows are created disabled.
|
||||
- Check the workflow's `CONDITION` steps aren't filtering everything out (verdict checks are a common culprit when the sender domain has loose authentication).
|
||||
|
||||
These are issues with the sender's configuration, not your Plunk setup. You can choose to process these emails anyway or filter them using workflow conditions.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Inbound email is dropped">
|
||||
|
||||
- Check **Billing → Limits** for an inbound cap that's been reached.
|
||||
- Check the project's status in the dashboard — a disabled project doesn't process inbound mail.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Verdicts are consistently FAIL">
|
||||
|
||||
This points at the sender's configuration, not yours:
|
||||
|
||||
- **SPF failures**: sender's domain has no SPF record or doesn't list their sending IP.
|
||||
- **DKIM failures**: sender's domain has no DKIM, or the message was modified in transit (some forwarding services break DKIM).
|
||||
- **DMARC failures**: sender fails both SPF and DKIM, or has strict alignment that forwarding broke.
|
||||
|
||||
You can either drop these in your workflow or process them anyway — your call.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</Accordions>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
title: Segment filter reference
|
||||
description: How to write filters for dynamic segments, campaign audiences, and workflow conditions
|
||||
icon: ListFilter
|
||||
---
|
||||
|
||||
The same filter format powers three things: **dynamic segments**, **campaign audiences** with `audienceType: "FILTERED"`, and **`CONDITION` steps inside workflows**. Learn it once, use it everywhere.
|
||||
|
||||
For a conceptual introduction, see the [Segments concept page](/concepts/segments).
|
||||
|
||||
## How a filter is structured
|
||||
|
||||
A filter has two parts: a top-level connector (`AND` or `OR`) and a list of **groups**. Each group holds one or more conditions, which are combined with `AND` inside the group. The top-level connector then joins the groups together.
|
||||
|
||||
That sounds complicated, so a quick example: "subscribed users **and** on the Pro plan **and** (opened **or** clicked an email recently)".
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "AND",
|
||||
"groups": [
|
||||
{
|
||||
"filters": [
|
||||
{ "field": "subscribed", "operator": "equals", "value": true },
|
||||
{ "field": "data.plan", "operator": "equals", "value": "pro" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"filters": [],
|
||||
"conditions": {
|
||||
"logic": "OR",
|
||||
"groups": [
|
||||
{ "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
|
||||
{ "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Groups can nest by setting `conditions` on a group — that lets you build "this AND (that OR this)" expressions without flattening logic. In the dashboard, the segment builder UI handles this for you visually.
|
||||
|
||||
## Fields you can filter on
|
||||
|
||||
The `field` value is namespaced by prefix — the prefix tells Plunk what part of the contact you're querying.
|
||||
|
||||
| Field pattern | Targets | Examples |
|
||||
| -------------------------- | ---------------------------------------------------------------- | --------------------------------- |
|
||||
| `email` | The contact's email address | `email` |
|
||||
| `subscribed` | The contact's subscription state | `subscribed` |
|
||||
| `createdAt`, `updatedAt` | Built-in contact timestamps | `createdAt` |
|
||||
| `data.<path>` | Custom contact data — supports nested paths | `data.plan`, `data.profile.tier` |
|
||||
| `event.<eventName>` | Custom events tracked via `/v1/track` | `event.signed_up` |
|
||||
| `email.<activity>` | Email engagement: `sent`, `delivered`, `opened`, `clicked`, `bounced`, `complained` | `email.opened` |
|
||||
| `segment.<segmentId>` | Membership of another segment | `segment.cuid_of_other_segment` |
|
||||
|
||||
## Operators
|
||||
|
||||
Operators are grouped by the kind of field they work on.
|
||||
|
||||
### Text fields
|
||||
|
||||
For `email` and any `data.<key>` that holds a string.
|
||||
|
||||
| Operator | What it does |
|
||||
| ------------- | --------------------------------------------------------- |
|
||||
| `equals` | Exact match. Case-insensitive on `email`, exact on `data.*`. |
|
||||
| `notEquals` | Negation of `equals`. |
|
||||
| `contains` | Case-insensitive substring match. |
|
||||
| `notContains` | Negation of `contains`. |
|
||||
|
||||
### Yes/no fields
|
||||
|
||||
For `subscribed` and any `data.<key>` that holds `true` or `false`.
|
||||
|
||||
| Operator | What it does |
|
||||
| ----------- | --------------------------- |
|
||||
| `equals` | Match true or false. |
|
||||
| `notEquals` | Negation. |
|
||||
|
||||
### Dates and timestamps
|
||||
|
||||
For `createdAt`, `updatedAt`, and any `data.<key>` that holds an ISO 8601 date string.
|
||||
|
||||
| Operator | What it does |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `equals` / `notEquals` | Match the timestamp. If the value is just a date (`YYYY-MM-DD`), the comparison covers the whole UTC day. |
|
||||
| `greaterThan` / `lessThan` | Strictly after / strictly before. |
|
||||
| `greaterThanOrEqual` / `lessThanOrEqual` | Inclusive variants. |
|
||||
| `within` | Within the last N units. Pair with `unit: "days" / "hours" / "minutes"`. |
|
||||
| `olderThan` | More than N units ago. Pair with `unit`. |
|
||||
|
||||
For custom date fields (`data.<key>`), `within` and `olderThan` only work when the value is stored as an ISO 8601 string (e.g. `"2026-05-06T12:00:00Z"`). Unix timestamps and other formats won't compare correctly.
|
||||
|
||||
### Numbers
|
||||
|
||||
For any `data.<key>` that holds a number: `equals`, `notEquals`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`. No `unit` is needed.
|
||||
|
||||
### Field existence
|
||||
|
||||
For any `data.<key>`, regardless of value type.
|
||||
|
||||
| Operator | What it does |
|
||||
| ----------- | ----------------------------------------------------- |
|
||||
| `exists` | The key is present on the contact and is not null. |
|
||||
| `notExists` | The key is missing or explicitly null. |
|
||||
|
||||
### Events and email activity
|
||||
|
||||
These operators apply to both `event.<eventName>` (custom events) and `email.<activity>` (email engagement).
|
||||
|
||||
| Operator | What it does |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `triggered` | The contact has had this event at least once, ever. |
|
||||
| `notTriggered` | The contact has never had this event. |
|
||||
| `triggeredWithin` | At least one occurrence in the last N units. Pair with `unit`. |
|
||||
| `triggeredOlderThan` | Has had this event, but not within the last N units. Pair with `unit`. |
|
||||
| `notTriggeredWithin` | Has not had this event in the last N units. Includes contacts who have never triggered it. Pair with `unit`. |
|
||||
|
||||
### Segment membership
|
||||
|
||||
For `segment.<segmentId>`, where `<segmentId>` is the ID of another segment in your project.
|
||||
|
||||
| Operator | What it does |
|
||||
| --------------------- | --------------------------------------------------------- |
|
||||
| `memberOfSegment` | Contact is currently in the referenced segment. |
|
||||
| `notMemberOfSegment` | Contact is not in the referenced segment. |
|
||||
|
||||
## Quick reference: what to pass with each operator
|
||||
|
||||
- **Need a `value`**: `equals`, `notEquals`, `contains`, `notContains`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `within`, `olderThan`, `triggeredWithin`, `triggeredOlderThan`, `notTriggeredWithin`.
|
||||
- **Need a `unit`** (`"days"`, `"hours"`, or `"minutes"`): `within`, `olderThan`, `triggeredWithin`, `triggeredOlderThan`, `notTriggeredWithin`.
|
||||
- **Need nothing extra**: `exists`, `notExists`, `triggered`, `notTriggered`, `memberOfSegment`, `notMemberOfSegment`.
|
||||
|
||||
## Examples
|
||||
|
||||
### Engaged users on the Pro plan
|
||||
|
||||
Subscribed Pro-plan users who opened or clicked any email in the last 14 days but haven't clicked anything in the last 30 days.
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "AND",
|
||||
"groups": [
|
||||
{
|
||||
"filters": [
|
||||
{ "field": "subscribed", "operator": "equals", "value": true },
|
||||
{ "field": "data.plan", "operator": "equals", "value": "pro" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"filters": [],
|
||||
"conditions": {
|
||||
"logic": "OR",
|
||||
"groups": [
|
||||
{ "filters": [{ "field": "email.opened", "operator": "triggeredWithin", "value": 14, "unit": "days" }] },
|
||||
{ "filters": [{ "field": "email.clicked", "operator": "triggeredWithin", "value": 14, "unit": "days" }] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"filters": [
|
||||
{ "field": "email.clicked", "operator": "notTriggeredWithin", "value": 30, "unit": "days" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### New trial users without a purchase
|
||||
|
||||
Subscribed contacts created in the last 7 days who haven't yet triggered a `purchase` event.
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "AND",
|
||||
"groups": [
|
||||
{
|
||||
"filters": [
|
||||
{ "field": "subscribed", "operator": "equals", "value": true },
|
||||
{ "field": "createdAt", "operator": "within", "value": 7, "unit": "days" },
|
||||
{ "field": "event.purchase", "operator": "notTriggered" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Power users above a tier
|
||||
|
||||
Members of the `power-users` segment whose lifetime value is at least 500.
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "AND",
|
||||
"groups": [
|
||||
{
|
||||
"filters": [
|
||||
{ "field": "segment.<powerUsersSegmentId>", "operator": "memberOfSegment" },
|
||||
{ "field": "data.lifetimeValue", "operator": "greaterThanOrEqual", "value": 500 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
<Cards>
|
||||
<Card title="Segments" href="/concepts/segments">
|
||||
Static vs dynamic segments, membership tracking, and entry / exit events.
|
||||
</Card>
|
||||
<Card title="Custom fields" href="/guides/custom-fields">
|
||||
How `data.*` fields are typed and used in filters.
|
||||
</Card>
|
||||
</Cards>
|
||||
@@ -1,32 +1,42 @@
|
||||
---
|
||||
title: Tracking
|
||||
description: Tracking for opens and clicks in your emails
|
||||
description: Track opens and clicks on the emails you send through Plunk
|
||||
icon: HatGlasses
|
||||
---
|
||||
|
||||
Plunk provides built-in tracking for email opens and link clicks. This allows you to monitor the engagement of your emails and gain insights into how your contacts interact with your content.
|
||||
Plunk provides built-in tracking for email opens and link clicks so you can monitor engagement and drive workflows or segments off email activity.
|
||||
|
||||
## Open tracking
|
||||
|
||||
When open tracking is enabled, Plunk includes a small, invisible tracking pixel in your emails. When a contact opens the email, the pixel is loaded, and Plunk records the open event.
|
||||
When open tracking is enabled, Plunk includes a small invisible tracking pixel at the bottom of your email. When the recipient's mail client loads the pixel, the open is recorded as an `email.open` event on the contact.
|
||||
|
||||
### Considerations
|
||||
- Open tracking relies on the loading of images in the email client. If a contact has images disabled, the open event may not be recorded.
|
||||
- Some email clients may pre-load images, which can result in false open events.
|
||||
|
||||
- Open tracking depends on the mail client loading remote images. If a contact has images disabled, no open is recorded.
|
||||
- Some clients (and corporate mail security gateways) pre-fetch images, which can produce false positive opens.
|
||||
- Apple Mail Privacy Protection masks individual opens — opens from Apple Mail are recorded but reflect proxy fetches rather than human reads.
|
||||
|
||||
## Click tracking
|
||||
|
||||
Click tracking is enabled by default for all emails sent through Plunk. When a contact clicks on a link in the email, Plunk records the click event and tracks which link was clicked.
|
||||
When click tracking is enabled, Plunk rewrites links in your email body to route through a tracking redirect. When a contact clicks a link, Plunk records the click and the original URL on an `email.click` event before forwarding the contact to the destination.
|
||||
|
||||
### Considerations
|
||||
- Click tracking works by rewriting the URLs in your email to point to Plunk's tracking servers
|
||||
- Some email clients or security software may block tracking links, which can result in missed click events
|
||||
|
||||
- Some mail clients and security tools pre-scan links, which can produce false positive clicks.
|
||||
- The rewrite is transparent to recipients but means link URLs in the email source no longer match the original destination.
|
||||
|
||||
## Configuration
|
||||
There are three levels of configuration for tracking:
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| Enabled | Tracking is enabled for all emails |
|
||||
| Disabled | Tracking is disabled for all emails |
|
||||
| Marketing only | Tracking is enabled only for marketing emails (templates and campaigns) |
|
||||
Tracking is configured per project under **Settings → Tracking**. Three modes are available:
|
||||
|
||||
| Mode | Behaviour |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Enabled** | Tracking is applied to every email. |
|
||||
| **Disabled** | No tracking is applied to any email. |
|
||||
| **Marketing only** | Tracking is applied to marketing sends (campaigns and emails sent by workflow steps using marketing or headless templates). Transactional sends — anything via `/v1/send` using a transactional template, or campaigns of type `TRANSACTIONAL` — are sent without tracking. |
|
||||
|
||||
Tracking is **all or nothing** within a single send — there's no separate toggle for opens vs clicks. Pick the mode that fits your privacy and analytics requirements.
|
||||
|
||||
<Callout title="Tracking mode availability" type="info">
|
||||
On self-hosted instances, the Marketing-only mode is only available when a no-tracking configuration set is configured. See [Self-hosting → Email setup](/self-hosting/email-setup) for details.
|
||||
</Callout>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: Unsubscribe & preferences pages
|
||||
description: Plunk's hosted pages for letting recipients unsubscribe, resubscribe, and manage their email preferences
|
||||
icon: UserX
|
||||
---
|
||||
|
||||
Every email Plunk sends to a recipient who can unsubscribe (marketing or headless templates) carries a personalized link to a hosted page where they can manage their subscription. You don't need to build any of this — Plunk hosts the pages, handles the state changes, and tracks the events for you.
|
||||
|
||||
## The three URL variables
|
||||
|
||||
Three template variables are auto-injected on every send and resolve to per-recipient signed URLs:
|
||||
|
||||
| Variable | Page it links to | What the recipient can do |
|
||||
| -------------------- | ----------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `{{unsubscribeUrl}}` | One-click unsubscribe page | Confirm they want to stop receiving marketing emails |
|
||||
| `{{subscribeUrl}}` | Resubscribe page | Opt back in after previously unsubscribing |
|
||||
| `{{manageUrl}}` | Preferences page | View their current state and toggle subscription either way |
|
||||
|
||||
Use them anywhere in a template's body or subject line. The placeholder is replaced with a unique URL when the email is rendered for that specific contact.
|
||||
|
||||
## Default behaviour by template type
|
||||
|
||||
Where Plunk drops these links depends on the template type:
|
||||
|
||||
| Template type | Auto footer with `{{unsubscribeUrl}}`? | Notes |
|
||||
| ----------------- | -------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **Marketing** | Yes — Plunk appends a localized footer | You don't need to do anything. The footer respects the recipient's `locale`. |
|
||||
| **Headless** | No | You must include `{{unsubscribeUrl}}` (or `{{manageUrl}}`) in your body or the recipient has no way to opt out. |
|
||||
| **Transactional** | No | Transactional emails skip subscription checks; an unsubscribe link is not added (and shouldn't be needed). |
|
||||
|
||||
If you build your own footer in a marketing template, you can still rely on the auto-injected one or override it by including your own link with `{{unsubscribeUrl}}`.
|
||||
|
||||
## Inside transactional sends
|
||||
|
||||
`/v1/send` accepts the same template variables. Whether an unsubscribe footer is appended depends on the template you reference:
|
||||
|
||||
- Sending with no template (just `subject` + `body`) → no auto footer.
|
||||
- Sending with a marketing template → footer auto-injected.
|
||||
- Sending with a headless or transactional template → no footer (you control the body).
|
||||
|
||||
You can always reference `{{unsubscribeUrl}}` / `{{manageUrl}}` in your inline body if you want to render your own link.
|
||||
|
||||
## What happens when the recipient acts
|
||||
|
||||
When the recipient clicks one of the links and confirms:
|
||||
|
||||
| Action | Effect |
|
||||
| ----------------------- | --------------------------------------------------------------------------------- |
|
||||
| Unsubscribe | Contact's `subscribed` flips to `false`. `contact.unsubscribed` event fires. |
|
||||
| Resubscribe | Contact's `subscribed` flips to `true`. `contact.subscribed` event fires. |
|
||||
| Update via preferences | Same as above, depending on which way they toggle. |
|
||||
|
||||
You can drive workflows off `contact.unsubscribed` / `contact.subscribed` (e.g. send a "we're sorry to see you go" survey, or trigger a winback when they re-opt-in).
|
||||
|
||||
## Localization
|
||||
|
||||
The hosted pages and the auto-injected footer are localized into the contact's `locale` if set, or the project's default language otherwise. See [Localization](/guides/localization) for the full list of supported languages.
|
||||
|
||||
## Branding
|
||||
|
||||
The pages use your project's name and logo (configurable in **Settings → Project**). For more advanced customization, send marketing as **headless** templates and build the unsubscribe surface into your own product UI — link your in-product unsubscribe controls to the same `{{unsubscribeUrl}}` for one-click revocation.
|
||||
|
||||
## API reference
|
||||
|
||||
If you need to drive subscription changes programmatically rather than through the hosted pages — for example because you've built your own preferences UI — use the contacts API:
|
||||
|
||||
- `PATCH /contacts/:id` with `{ "subscribed": false }` — unsubscribe a contact.
|
||||
- `PATCH /contacts/:id` with `{ "subscribed": true }` — resubscribe.
|
||||
|
||||
Both flips automatically emit the corresponding `contact.subscribed` / `contact.unsubscribed` event, just like the hosted pages do.
|
||||
@@ -40,6 +40,10 @@ You'll need to add **1 TXT record** that lists the authorized sending servers.
|
||||
- Reduces the likelihood of your domain being used for spam
|
||||
- Works together with DKIM for complete authentication
|
||||
|
||||
<Callout title="Already have an SPF record?" type="warn">
|
||||
A domain can only have **one** SPF TXT record. If you already use another email provider (Google Workspace, Microsoft 365, another sending platform), you must **merge** Plunk's SPF mechanism into your existing record — don't add a second SPF record. For example, if your existing record is `v=spf1 include:_spf.google.com ~all`, the merged version is `v=spf1 include:_spf.google.com include:<Plunk's include from the dashboard> ~all`. Two separate SPF records will cause both to fail.
|
||||
</Callout>
|
||||
|
||||
### Bounce Handling (1 MX record)
|
||||
|
||||
This **MX record** allows Plunk to receive bounce notifications and spam complaints from email providers.
|
||||
@@ -51,36 +55,62 @@ This **MX record** allows Plunk to receive bounce notifications and spam complai
|
||||
- Prevents sending to invalid email addresses
|
||||
- Required for deliverability monitoring
|
||||
|
||||
<Callout title="Receiving inbound emails" variant="idea">
|
||||
The MX record above is for sending emails (handling bounces and complaints). If you want to **receive** emails at your
|
||||
domain and trigger workflows, see the [receiving emails](/guides/receiving-emails) guide for additional MX record
|
||||
setup.
|
||||
<Callout title="Bounce MX vs inbound MX" type="info">
|
||||
This MX record handles delivery feedback (bounces, complaints) for emails Plunk sends out — it's separate from the [inbound MX record](/guides/receiving-emails) used to **receive** emails sent to your domain. They serve different purposes; you can have either one or both.
|
||||
</Callout>
|
||||
|
||||
### DMARC (optional, recommended)
|
||||
|
||||
**Domain-based Message Authentication, Reporting and Conformance (DMARC)** tells receiving mail servers what to do when an email fails SPF or DKIM checks, and where to send authentication reports.
|
||||
|
||||
Plunk doesn't require DMARC, but most major mailbox providers (Gmail, Yahoo, Microsoft) now expect it for bulk senders. Add a TXT record at `_dmarc.yourdomain.com`:
|
||||
|
||||
```
|
||||
v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com
|
||||
```
|
||||
|
||||
Start with `p=none` to monitor without affecting delivery. Once you've reviewed reports for a couple of weeks and confirmed all your legitimate senders are authenticated, tighten to `p=quarantine` and eventually `p=reject`.
|
||||
|
||||
### MAIL FROM domain (optional)
|
||||
|
||||
You can configure a custom MAIL FROM domain (typically a subdomain like `mail.yourdomain.com`) so the bounce envelope address aligns with your sending domain. This improves DMARC alignment and is required by some inbox providers for full pass-through.
|
||||
|
||||
If you set this up, you'll need an additional MX record and TXT record on the subdomain — Plunk's dashboard will show you the exact values to add when you enable it on a verified domain.
|
||||
|
||||
## Verification Status
|
||||
|
||||
After adding all DNS records, Plunk will automatically check the verification status. Verification typically completes within a few minutes but can take up to 72 hours depending on DNS propagation.
|
||||
After adding all DNS records, Plunk automatically checks verification status in the background. Verification typically completes within a few minutes, but can take up to 72 hours depending on DNS propagation.
|
||||
|
||||
You can check the status in the Domains section of your project settings. Each record type will show as verified once detected.
|
||||
You can check the status in the Domains section of your project settings. Each record type will show as verified once detected. If you've just added a record and don't want to wait, you can trigger a re-check from the dashboard.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Records not verifying
|
||||
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
|
||||
|
||||
<Accordions type="single">
|
||||
|
||||
<Accordion title="Records not verifying">
|
||||
|
||||
If your DNS records aren't verifying after 24 hours:
|
||||
|
||||
1. **Double-check the values**: Ensure you copied the exact values without extra spaces
|
||||
2. **Check DNS propagation**: Use tools like `dig` or online DNS checkers to verify the records are published
|
||||
3. **TTL settings**: Some DNS providers cache records. Try lowering the TTL (Time To Live) value
|
||||
4. **Contact your DNS provider**: Some providers have specific requirements or interfaces for adding these record types
|
||||
1. **Double-check the values**: ensure you copied the exact values without extra spaces.
|
||||
2. **Check DNS propagation**: use tools like `dig` or online DNS checkers to verify the records are published.
|
||||
3. **TTL settings**: some DNS providers cache records. Try lowering the TTL (Time To Live) value.
|
||||
4. **Contact your DNS provider**: some providers have specific requirements or interfaces for adding these record types.
|
||||
|
||||
### Emails still going to spam
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Emails still going to spam">
|
||||
|
||||
Even with a verified domain, emails may go to spam if:
|
||||
|
||||
- Your content triggers spam filters (excessive links, suspicious keywords)
|
||||
- Your sender reputation is new or low
|
||||
- Recipients have marked your emails as spam in the past
|
||||
- You're sending to invalid or unengaged contacts
|
||||
- Your content triggers spam filters (excessive links, suspicious keywords).
|
||||
- Your sender reputation is new or low.
|
||||
- Recipients have marked your emails as spam in the past.
|
||||
- You're sending to invalid or unengaged contacts.
|
||||
|
||||
Follow email best practices and maintain good [list hygiene](/guides/list-hygiene) to improve deliverability.
|
||||
|
||||
</Accordion>
|
||||
|
||||
</Accordions>
|
||||
|
||||
@@ -5,6 +5,7 @@ icon: Webhook
|
||||
---
|
||||
|
||||
import {Tab, Tabs} from 'fumadocs-ui/components/tabs';
|
||||
import {TypeTable} from 'fumadocs-ui/components/type-table';
|
||||
|
||||
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.
|
||||
|
||||
@@ -48,19 +49,27 @@ Plunk automatically tracks a set of internal events that you can use as workflow
|
||||
| `segment.<name>.entry` | A contact entered a segment |
|
||||
| `segment.<name>.exit` | A contact exited a segment |
|
||||
|
||||
<Callout title="Segment event names" variant="idea">
|
||||
<Callout title="Segment event names" type="info">
|
||||
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce
|
||||
the events `segment.vip-users.entry` and `segment.vip-users.exit`.
|
||||
</Callout>
|
||||
|
||||
## Setting up a webhook
|
||||
|
||||
<div className='fd-steps [&_h3]:fd-step'>
|
||||
import {Step, Steps} from 'fumadocs-ui/components/steps';
|
||||
|
||||
<Steps>
|
||||
|
||||
<Step>
|
||||
|
||||
### Create the workflow
|
||||
|
||||
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Add a Webhook step
|
||||
|
||||
After the trigger, add a **Webhook** step and configure it:
|
||||
@@ -75,11 +84,17 @@ After the trigger, add a **Webhook** step and configure it:
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
|
||||
### Enable the workflow
|
||||
|
||||
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
|
||||
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
|
||||
## Webhook payload
|
||||
|
||||
@@ -126,18 +141,20 @@ The `event` field contains the data associated with the event that triggered the
|
||||
|
||||
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 (for correlating with SES events) |
|
||||
| `emailId` | The Plunk email record ID (returned from `POST /v1/send`, for correlating webhooks with API responses) |
|
||||
| `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` |
|
||||
<TypeTable
|
||||
type={{
|
||||
subject: { type: 'string', description: 'The email subject line.' },
|
||||
from: { type: 'string', description: 'The sender email address.' },
|
||||
fromName: { type: 'string', description: 'The sender display name.' },
|
||||
messageId: { type: 'string', description: 'Provider-side message identifier (useful for correlating with delivery logs).' },
|
||||
emailId: { type: 'string', description: 'The Plunk email record ID — returned from `POST /v1/send`, used for correlating webhooks with API responses.' },
|
||||
templateId: { type: 'string', description: 'The template ID, if the email was sent using a template. Otherwise `null`.' },
|
||||
campaignId: { type: 'string', description: 'The campaign ID, if the email was part of a campaign. Otherwise `null`.' },
|
||||
sourceType: { type: 'string', description: 'How the email was triggered. One of `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND`.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
In addition to these base fields, each event includes the following event-specific fields:
|
||||
In addition to these base fields, each event includes the following event-specific fields. The base fields above (`subject`, `from`, `fromName`, `messageId`, `emailId`, `templateId`, `campaignId`, `sourceType`) are present on every email event in addition to the event-specific fields shown below.
|
||||
|
||||
<Tabs items={['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint', 'email.received']}>
|
||||
|
||||
@@ -157,9 +174,11 @@ In addition to these base fields, each event includes the following event-specif
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------- | ----------------------- |
|
||||
| `sentAt` | When the email was sent |
|
||||
<TypeTable
|
||||
type={{
|
||||
sentAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was accepted for delivery.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -179,9 +198,11 @@ In addition to these base fields, each event includes the following event-specif
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | ---------------------------- |
|
||||
| `deliveredAt` | When the email was delivered |
|
||||
<TypeTable
|
||||
type={{
|
||||
deliveredAt: { type: 'string', description: 'ISO 8601 timestamp of when the email was delivered to the recipient.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -203,11 +224,13 @@ In addition to these base fields, each event includes the following event-specif
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
<TypeTable
|
||||
type={{
|
||||
openedAt: { type: 'string', description: 'ISO 8601 timestamp of the first open.' },
|
||||
opens: { type: 'number', description: 'Total number of times this email has been opened.' },
|
||||
isFirstOpen: { type: 'boolean', description: '`true` if this is the first time the contact opened this email.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -230,12 +253,14 @@ In addition to these base fields, each event includes the following event-specif
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
<TypeTable
|
||||
type={{
|
||||
link: { type: 'string', description: 'The URL that was clicked.' },
|
||||
clickedAt: { type: 'string', description: 'ISO 8601 timestamp of the first click.' },
|
||||
clicks: { type: 'number', description: 'Total number of clicks on links in this email.' },
|
||||
isFirstClick: { type: 'boolean', description: '`true` if this is the first click from this contact on this email.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -275,13 +300,15 @@ Transient (soft) bounce:
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
<TypeTable
|
||||
type={{
|
||||
bounceType: { type: 'string', description: '`Permanent` for hard bounces, `Transient` for soft bounces (mailbox full, out-of-office, greylist).' },
|
||||
bouncedAt: { type: 'string', description: 'ISO 8601 timestamp of when the bounce occurred. Only present on permanent bounces.' },
|
||||
transientBounce: { type: 'boolean', description: '`true` for soft bounces — these do not count toward your bounce rate and the contact stays subscribed.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Callout title="Bounce rate impact" variant="warn">
|
||||
<Callout title="Bounce rate impact" type="warn">
|
||||
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
|
||||
`Transient` bounces are tracked for visibility only.
|
||||
</Callout>
|
||||
@@ -304,9 +331,11 @@ Transient (soft) bounce:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------------- | ------------------------------------ |
|
||||
| `complainedAt` | When the spam complaint was received |
|
||||
<TypeTable
|
||||
type={{
|
||||
complainedAt: { type: 'string', description: 'ISO 8601 timestamp of when the complaint was received.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -334,23 +363,25 @@ This event fires when an email is received at your verified domain. See [Receivi
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
| `body` | HTML body of the email (or plain text if no HTML 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 |
|
||||
<TypeTable
|
||||
type={{
|
||||
messageId: { type: 'string', description: 'Unique identifier for the received message.' },
|
||||
from: { type: 'string', description: "The sender's email address." },
|
||||
fromHeader: { type: 'string', description: 'The full `From` header, including display name if present.' },
|
||||
to: { type: 'string', description: 'The recipient address at your verified domain.' },
|
||||
subject: { type: 'string', description: 'The email subject line.' },
|
||||
timestamp: { type: 'string', description: 'ISO 8601 timestamp when the email was received.' },
|
||||
recipients: { type: 'array of strings', description: 'All recipient addresses in the envelope.' },
|
||||
hasContent: { type: 'boolean', description: 'Whether the email body content is available.' },
|
||||
body: { type: 'string', description: 'Sanitized HTML body of the email (or plain text if no HTML available).' },
|
||||
spamVerdict: { type: 'string', description: 'Spam check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
|
||||
virusVerdict: { type: 'string', description: 'Virus check result. One of `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED`.' },
|
||||
spfVerdict: { type: 'string', description: 'SPF authentication result.' },
|
||||
dkimVerdict: { type: 'string', description: 'DKIM authentication result.' },
|
||||
dmarcVerdict: { type: 'string', description: 'DMARC authentication result.' },
|
||||
processingTimeMillis: { type: 'number', description: 'Time taken to process the inbound email.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -368,9 +399,11 @@ The exception is when an unsubscription is triggered automatically by an email b
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
| -------- | --------------------------------------------------- |
|
||||
| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
|
||||
<TypeTable
|
||||
type={{
|
||||
reason: { type: 'string', description: 'Why the contact was unsubscribed. One of `bounce` or `complaint`. Only present when triggered automatically by a bounce or complaint.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Segment events
|
||||
|
||||
@@ -383,10 +416,12 @@ Both `segment.<name>.entry` and `segment.<name>.exit` include:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ------------- | ------------------------------- |
|
||||
| `segmentId` | The ID of the segment |
|
||||
| `segmentName` | The display name of the segment |
|
||||
<TypeTable
|
||||
type={{
|
||||
segmentId: { type: 'string', description: 'The ID of the segment.' },
|
||||
segmentName: { type: 'string', description: 'The display name of the segment.' },
|
||||
}}
|
||||
/>
|
||||
|
||||
#### Custom events
|
||||
|
||||
@@ -440,7 +475,7 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the SES `messageId`.
|
||||
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the provider `messageId`.
|
||||
|
||||
## Common use cases
|
||||
|
||||
@@ -462,6 +497,27 @@ Trigger a workflow on `contact.unsubscribed` to notify your application when a c
|
||||
|
||||
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
|
||||
|
||||
## Receiving webhooks safely
|
||||
|
||||
Plunk's webhook step has a few characteristics worth knowing when you build the receiving endpoint:
|
||||
|
||||
- **Method**: defaults to `POST` with `Content-Type: application/json`. You can override the method per step.
|
||||
- **Timeout**: each request times out after **10 seconds**. Long-running endpoints should accept the request, queue the work, and return `2xx` quickly.
|
||||
- **Redirects**: up to 5 redirects are followed. Each hop is re-validated against the SSRF rules below.
|
||||
- **Public URL required**: your webhook endpoint must be reachable on the public internet. Webhooks pointed at private or internal addresses (loopback, RFC 1918 ranges, etc.) won't be delivered.
|
||||
- **Schemes**: only `http://` and `https://` are accepted. Prefer HTTPS.
|
||||
- **No automatic retries**: a non-2xx response or timeout fails the workflow step. Build idempotency into your handler and use workflow logic (a `WAIT_FOR_EVENT` step, a fallback branch) if you need retry semantics.
|
||||
- **Verify authenticity with a shared secret**: configure a secret header on the webhook step and check it on your endpoint:
|
||||
|
||||
```json
|
||||
// Webhook step → Headers
|
||||
{
|
||||
"Authorization": "Bearer your-shared-secret"
|
||||
}
|
||||
```
|
||||
|
||||
The secret travels with every request from that step. Rotate it like any other shared secret. Prefer this over IP allowlisting — egress IPs can change.
|
||||
|
||||
## Adding conditions and delays
|
||||
|
||||
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
|
||||
|
||||
@@ -6,7 +6,7 @@ icon: House
|
||||
|
||||
## What is Plunk?
|
||||
|
||||
Plunk is an open-source email platform built on top of AWS SES. It is designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built with modern technologies and designed to handle millions of contacts, Plunk provides everything you need to manage your email communications.
|
||||
Plunk is an open-source email platform designed for developers who need a powerful, scalable solution for transactional and marketing emails. Built to handle millions of contacts, it provides everything you need to manage your email communications — sending, automation, segmentation, deliverability, and analytics.
|
||||
|
||||
## Key Features
|
||||
|
||||
|
||||
@@ -44,12 +44,27 @@ See [Environment Variables](/self-hosting/environment-variables) for all options
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| 80 | Nginx (HTTP) |
|
||||
| 465 | SMTP (implicit TLS) |
|
||||
| 587 | SMTP (STARTTLS) |
|
||||
| 9000 | Minio API |
|
||||
| Port | Service |
|
||||
| ----- | ---------------------------------------- |
|
||||
| 80 | Nginx (HTTP) — fronts API, dashboard, landing, wiki |
|
||||
| 465 | SMTP (implicit TLS) |
|
||||
| 587 | SMTP (STARTTLS) |
|
||||
| 8080 | API server (proxied through nginx; not usually exposed externally) |
|
||||
| 9000 | Minio API |
|
||||
| 9001 | Minio web console |
|
||||
|
||||
When deploying behind a reverse proxy or load balancer, expose port 80 (and 465/587 if using SMTP). Postgres, Redis, and Minio are kept internal to the Docker network by default.
|
||||
|
||||
## Health check
|
||||
|
||||
The API exposes a health endpoint at `/health` for orchestrators (Docker Compose, Kubernetes, load balancers):
|
||||
|
||||
```bash
|
||||
curl https://api.yourdomain.com/health
|
||||
# { "status": "ok", "time": ..., "uptime": ... }
|
||||
```
|
||||
|
||||
Use this for liveness/readiness probes. It returns `200` when the API process is up and able to serve requests.
|
||||
|
||||
## Running Individual Services
|
||||
|
||||
|
||||
@@ -74,3 +74,49 @@ SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking"
|
||||
|
||||
Once you have configured AWS SES with the above settings, you can add and verify your domain directly through the Plunk dashboard. Plunk will handle the domain verification and DKIM setup with AWS SES automatically and show you the right records to add to your DNS.
|
||||
|
||||
## 6. (Optional) Configure Inbound Email
|
||||
|
||||
If you want to use Plunk's [Receiving emails](/guides/receiving-emails) feature, configure SES inbound separately. Plunk does not auto-provision receipt rules, so this is a manual one-time setup in your AWS account.
|
||||
|
||||
Not all AWS regions support SES inbound — confirm `inbound-smtp.<your-region>.amazonaws.com` exists for your `AWS_SES_REGION` before continuing. If your region doesn't support inbound, you can keep outbound on your current region and run a separate identity in a region that does.
|
||||
|
||||
### Create a receipt rule set
|
||||
|
||||
1. SES Console → **Email receiving** → **Rule sets** → Create rule set (or use an existing one)
|
||||
2. Add a rule:
|
||||
- **Recipient conditions**: `*@yourdomain.com` (or specific addresses you want to receive)
|
||||
- **Actions**: Publish to Amazon SNS topic → select your `plunk-ses-events` topic (or create a separate topic that's also subscribed to `https://api.yourdomain.com/webhooks/sns`)
|
||||
3. Set the rule set as **active**
|
||||
|
||||
### Inbound IAM permissions
|
||||
|
||||
Plunk doesn't call SES inbound APIs at runtime — the IAM policy from step 1 covers everything Plunk needs. Configuring receipt rules in AWS is a manual one-time action you do as an AWS admin.
|
||||
|
||||
### Add an MX record
|
||||
|
||||
For each verified domain you want to receive on, add an MX record pointing at the SES inbound endpoint for your region:
|
||||
|
||||
```
|
||||
Type: MX
|
||||
Name: yourdomain.com
|
||||
Value: 10 inbound-smtp.<your-region>.amazonaws.com
|
||||
```
|
||||
|
||||
Plunk's dashboard will show the exact value to use for your configured region.
|
||||
|
||||
## 7. (Optional) Move Out of the SES Sandbox
|
||||
|
||||
By default, new AWS SES accounts are in **sandbox mode** with strict limits:
|
||||
|
||||
- Send only to **verified** addresses
|
||||
- 200 messages per 24 hours
|
||||
- 1 message per second
|
||||
|
||||
Sandbox is fine for testing but useless for production. Request production access in the SES console (**Account dashboard** → **Request production access**) — AWS typically approves within 24 hours after a short questionnaire about your sending practices.
|
||||
|
||||
After approval, your sending quota will reflect a much higher daily and per-second limit specific to your account.
|
||||
|
||||
## 8. (Optional) Configure a MAIL FROM Domain
|
||||
|
||||
For better DMARC alignment, you can configure a custom MAIL FROM subdomain (e.g. `mail.yourdomain.com`). In the SES console under **Verified identities** → your domain → **MAIL FROM domain**, set a subdomain and add the additional MX and TXT records SES displays. Plunk's IAM policy already includes `ses:SetIdentityMailFromDomain` to support this.
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ description: Configuration reference
|
||||
|
||||
| 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) |
|
||||
| `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 used by the application at runtime (typically through PgBouncer). Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
|
||||
| `DIRECT_DATABASE_URL` | Yes | Direct PostgreSQL connection string used by Prisma migrations. Must bypass any connection pooler. Auto-configured in Docker. | `postgresql://plunk:password@postgres:5432/plunk` |
|
||||
| `REDIS_URL` | Yes | Redis connection string. | `redis://redis:6379` |
|
||||
| `NODE_ENV` | No | Application environment. Set to `production` for production deployments. | `production` |
|
||||
| `PORT` | No | Port the API server listens on. | `8080` (default) |
|
||||
|
||||
## URLs & Domains
|
||||
|
||||
@@ -52,6 +54,15 @@ The bundled Docker setup includes Minio with defaults that work out of the box.
|
||||
| `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` |
|
||||
|
||||
## Attachments
|
||||
|
||||
Plunk supports attachments on transactional emails. AWS SES caps total message size at 40 MB; the defaults below leave headroom but can be tuned for your use case.
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||
| `MAX_ATTACHMENT_SIZE_MB` | No | Maximum total attachment size in megabytes per email. Hard upper bound enforced by AWS SES is 40 MB. | `10` |
|
||||
| `MAX_ATTACHMENTS_COUNT` | No | Maximum number of attachments per email. | `10` |
|
||||
|
||||
## SMTP Server
|
||||
|
||||
The optional SMTP relay lets you send emails through Plunk via the SMTP protocol.
|
||||
@@ -128,7 +139,7 @@ Plunk can use AI to detect and block phishing emails before they're sent. Requir
|
||||
| `OPENROUTER_API_KEY` | No | OpenRouter API key. When set, enables AI-powered phishing detection. | — |
|
||||
| `OPENROUTER_MODEL` | No | LLM model to use for content analysis. See [OpenRouter models](https://openrouter.ai/models). | `anthropic/claude-3-haiku` |
|
||||
| `PHISHING_DETECTION_SAMPLE_RATE` | No | Percentage of emails to check (0.0-1.0). For example, `0.1` means 10% of emails are analyzed. | `0.1` (10%) |
|
||||
| `PHISHING_CONFIDENCE_THRESHOLD` | No | Minimum confidence percentage (0-100) required to auto-disable a project from a single detection. | `85` |
|
||||
| `PHISHING_CONFIDENCE_THRESHOLD` | No | Minimum confidence percentage (0-100) required to auto-disable a project from a single detection. | `95` |
|
||||
| `PHISHING_CUMULATIVE_THRESHOLD` | No | Number of phishing detections within the time window required to auto-disable a project. | `3` |
|
||||
| `PHISHING_CUMULATIVE_WINDOW_MS` | No | Time window in milliseconds for cumulative phishing tracking. | `3600000` (1 hour) |
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import {renderOpenAPIOperationFromMDX} from '@/lib/render-openapi-operation';
|
||||
import {source} from '@/lib/source';
|
||||
import type {InferPageType} from 'fumadocs-core/source';
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const processed = await page.data.getText('processed');
|
||||
const isOpenAPI = Boolean((page.data as {_openapi?: unknown})._openapi);
|
||||
|
||||
if (isOpenAPI) {
|
||||
const raw = await page.data.getText('raw');
|
||||
const rendered = await renderOpenAPIOperationFromMDX(raw, page.data.title, page.url);
|
||||
if (rendered) return rendered;
|
||||
}
|
||||
|
||||
const processed = await page.data.getText('processed');
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user