Compare commits

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 09:10:30 +00:00
138 changed files with 2054 additions and 10267 deletions
+4 -13
View File
@@ -1,22 +1,13 @@
# Dependencies
node_modules
**/node_modules
.pnp
.pnp.js
# Build outputs
# 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
dist
.next
.turbo
out
# Development
.env
+14 -30
View File
@@ -52,13 +52,6 @@ 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
# ========================================
@@ -67,6 +60,15 @@ 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)
# ========================================
@@ -152,29 +154,11 @@ SMTP_DOMAIN=smtp.example.com
# Default: false
# DISABLE_SIGNUPS=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
# Number of emails the worker processes in parallel. When unset, concurrency is
# derived from the effective rate limit (~ rate * 0.5, min 5, capped by
# EMAIL_WORKER_MAX_CONCURRENCY) so a higher SES quota translates into higher
# throughput automatically. Pin this only when the Prisma pool or memory is the
# binding constraint.
# Default: unset (auto-derived)
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound applied to the auto-derived concurrency. Raise this when your SES
# quota is high AND the Prisma connection pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# 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
# ========================================
# ADVANCED (rarely needed)
-12
View File
@@ -65,18 +65,6 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Tune Postgres for ephemeral CI workload
env:
PGPASSWORD: postgres
run: |
# synchronous_commit=off is the biggest single I/O win and is safe to lose
# data on crash for a throwaway CI database.
# synchronous_commit is dynamic — applies on reload. max_connections would
# require a restart, so we leave it at the default of 100 and cap workers
# at 4 × connection_limit=20 = 80 to stay under that budget.
psql -h localhost -U postgres -d plunk_test -c "ALTER SYSTEM SET synchronous_commit = 'off';"
psql -h localhost -U postgres -d plunk_test -c "SELECT pg_reload_conf();"
- name: Setup environment variables
run: |
cat > .env << EOF
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.12.0"
".": "0.10.0"
}
-98
View File
@@ -1,103 +1,5 @@
# Changelog
## [0.12.0](https://github.com/useplunk/plunk/compare/v0.11.0...v0.12.0) (2026-05-27)
### Features
* add disabledReason field to projects for better tracking of disable reasons ([94ceadb](https://github.com/useplunk/plunk/commit/94ceadbbe417f0cb3ab72c66bfad9428bdce2d11))
* **api:** allow API key authentication for domain endpoints ([d2496bc](https://github.com/useplunk/plunk/commit/d2496bc51d17657380160dbfc20e099d3f4eadba))
* **contacts:** make email cell a link to the contact detail page ([6759bff](https://github.com/useplunk/plunk/commit/6759bffd2a1f0bfb09b754bb0efdfe5c347ea2b3))
* **contacts:** make email cell a link to the contact detail page ([6d98d51](https://github.com/useplunk/plunk/commit/6d98d512222ffb422bc2a4bef4284a644558b39d))
* **EmailService:** add worker concurrency settings and improve email queue prioritization ([80beb2b](https://github.com/useplunk/plunk/commit/80beb2bb9937101d1723f2d54648060fdcbe6cef))
* make detectCustomHtmlPatterns aware of TipTap's actual capabilities ([9797aed](https://github.com/useplunk/plunk/commit/9797aed47f520aba51bec4e09c9bc4b7762e02f8))
* make detectCustomHtmlPatterns aware of TipTap's actual capabilities ([ba3813e](https://github.com/useplunk/plunk/commit/ba3813e2422d9fe4e252a90242393cada65644dc))
* render template variables in WEBHOOK step url, headers and body ([bfecf04](https://github.com/useplunk/plunk/commit/bfecf04fa38309804ee19e99b9908caf51c5e039))
* render template variables in WEBHOOK step url, headers and body ([c484da8](https://github.com/useplunk/plunk/commit/c484da88ab7b4635987868e2e3c6fdded68ade8c))
* **SecurityService:** enhance phishing detection by verifying sender domains and institutional TLDs ([6ab4d77](https://github.com/useplunk/plunk/commit/6ab4d77ca9bebd01f75d8799dad23fdec7768b52))
* **SecurityService:** enhance phishing detection by verifying sender domains and institutional TLDs ([edfc399](https://github.com/useplunk/plunk/commit/edfc399061cb7fd079b7f11167a2796df766d8ac))
* **tests:** enhance test database setup and cleanup for improved isolation and performance ([32dd7bb](https://github.com/useplunk/plunk/commit/32dd7bba462a6f4a13d59b8fb03708f971d1eff0))
### Bug Fixes
* coerce boolean and numeric values in custom CSV columns ([4a145f3](https://github.com/useplunk/plunk/commit/4a145f3488bee63c2d82a4a477f6dfaaaaede64b))
* **filters:** land templates/workflows/campaigns search inputs at 32px to match filter buttons ([283f402](https://github.com/useplunk/plunk/commit/283f40239dcc81e78d35b39cb9b31f97b435b8d7))
* make email templates, campaigns and workflow search inputs same height as the rest of the app ([8b3657d](https://github.com/useplunk/plunk/commit/8b3657d056938b17f0634c0fe8d664ae1e07cd73))
### Code Refactoring
* **database:** increase Prisma connection pool limits for improved test performance ([71e2277](https://github.com/useplunk/plunk/commit/71e227764319e6fc732aefdebd11f3e844257ef6))
* **SecurityService:** update absolute count ceilings for new projects to improve spam detection ([4de40f4](https://github.com/useplunk/plunk/commit/4de40f40fa300c9e9ed2ed35f367ca4cd601f309))
### Documentation
* add env-var sync rule to CLAUDE.md ([a348d37](https://github.com/useplunk/plunk/commit/a348d37c21ecf1d57845f31210f66a8113fadb8d))
* add new recipe pages for waitlist and sync unsubscribes ([01ec34a](https://github.com/useplunk/plunk/commit/01ec34a8cbbe0f4a158398c0eb8300d847bc29ab))
* correct PHISHING_CONFIDENCE_THRESHOLD default in CLAUDE.md ([5c16679](https://github.com/useplunk/plunk/commit/5c166797b57f2e7b714957824ee1acdcb22c05f2))
* **env:** add wiki-documented vars to apps/api/.env.example ([81315ea](https://github.com/useplunk/plunk/commit/81315eac8eb32a7d2e61617c6a84f349afaf7860))
* **env:** sync .env.self-host.example with missing variables ([1c1c95d](https://github.com/useplunk/plunk/commit/1c1c95d332fb9ab6c2f926f6d51071cfb245ed86))
* **env:** sync env example files, fix CLAUDE.md drift, add process rule ([6ebbb50](https://github.com/useplunk/plunk/commit/6ebbb50f6817f1354f26cc7734631d3b6ded32ed))
* **wiki:** document MAIL_FROM_SUBDOMAIN and NGINX_PORT env vars ([971b98a](https://github.com/useplunk/plunk/commit/971b98a4cc6145571204f5a4ad794aa3d09ea71c))
## [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)
+1 -16
View File
@@ -161,7 +161,7 @@ Required for builds and deployment (see turbo.json and .env.example):
- `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection)
- `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis
- `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%)
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 95) - Minimum confidence percentage (0-100) to auto-disable project for single detection
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 85) - Minimum confidence percentage (0-100) to auto-disable project for single detection
- `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable
- `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour)
@@ -174,21 +174,6 @@ Required for builds and deployment (see turbo.json and .env.example):
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access
## Environment Variable Changes
When you add, rename, remove, or change the default/behaviour of any environment variable, you MUST update all THREE of the following in the same change:
1. `apps/api/.env.example` — local development defaults
2. `.env.self-host.example` — self-hosting / production template
3. `apps/wiki/content/docs/self-hosting/environment-variables.mdx` — user-facing reference
Rules:
- If the variable already exists in any file, **modify** its line/row/description — do not duplicate or leave a stale entry.
- Keep section/category names consistent across all three files (e.g. "AWS SES", "Phishing Detection").
- For dev-only or self-host-only variables, still mention them in the wiki and note the scope; only skip the example file where the variable is genuinely never applicable.
- When in doubt about whether a variable belongs in `apps/api/.env.example` (development), include it commented out with a short note.
## Plugins
There are two plugins installed for you to use.
-58
View File
@@ -8,8 +8,6 @@
# ==============================================================================
NODE_ENV=development
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
# Port the API server listens on (default: 8080)
# PORT=8080
# ==============================================================================
# Application URLs
@@ -27,8 +25,6 @@ LANDING_URI=http://localhost:4000
# ==============================================================================
# API key for authenticating with the Plunk API (obtained from dashboard)
PLUNK_API_KEY=
# From address used for platform notification emails (project disabled, billing limits, etc.)
# PLUNK_FROM_ADDRESS=
# ==============================================================================
# Database & Redis
@@ -65,17 +61,6 @@ SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optiona
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# Email worker concurrency (number of emails processed in parallel)
# If not set, derived from the effective rate limit (~ rate * 0.5, min 5, capped
# by EMAIL_WORKER_MAX_CONCURRENCY). Set this to pin a fixed value when the
# Prisma connection pool or memory is the binding constraint.
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound for auto-derived worker concurrency
# Raise this when your SES quota is high AND the Prisma pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
@@ -100,46 +85,3 @@ STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe mete
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
# ==============================================================================
# Notifications (Optional - system notifications via ntfy)
# ==============================================================================
# ntfy topic URL for internal system notifications (e.g. project disabled, billing limits).
# When unset, ntfy notifications are disabled.
# Examples:
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
# - Self-hosted: https://your-ntfy-server.com/your-topic
# NTFY_URL=
# ==============================================================================
# Attachments (Optional)
# ==============================================================================
# Limits applied to attachments on transactional emails.
# AWS SES caps total message size at 40 MB; the defaults below leave headroom.
# MAX_ATTACHMENT_SIZE_MB=10
# MAX_ATTACHMENTS_COUNT=10
# ==============================================================================
# User Management (Optional)
# ==============================================================================
# When 'true', the signup endpoint rejects new user registrations.
# Default: false
# DISABLE_SIGNUPS=false
# When 'true', validates emails on signup (disposable domains, plus-addressing,
# domain existence, MX records).
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ==============================================================================
# Phishing Detection (Optional - AI-powered phishing scan via OpenRouter)
# ==============================================================================
# When OPENROUTER_API_KEY is set, a random sample of outgoing emails is
# analyzed by an LLM. Projects can be auto-disabled if a single email exceeds
# PHISHING_CONFIDENCE_THRESHOLD, or if PHISHING_CUMULATIVE_THRESHOLD emails are
# flagged within PHISHING_CUMULATIVE_WINDOW_MS.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=anthropic/claude-3-haiku
# PHISHING_DETECTION_SAMPLE_RATE=0.1
# PHISHING_CONFIDENCE_THRESHOLD=95
# PHISHING_CUMULATIVE_THRESHOLD=3
# PHISHING_CUMULATIVE_WINDOW_MS=3600000
+1 -21
View File
@@ -45,12 +45,6 @@ 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)
@@ -58,20 +52,6 @@ export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECO
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Email Worker Concurrency (optional override)
// If not set, concurrency is derived from the effective rate limit so a higher
// SES quota actually translates into higher throughput. Set this to pin a fixed
// value (useful when Prisma pool size or memory is the binding constraint).
export const EMAIL_WORKER_CONCURRENCY = process.env.EMAIL_WORKER_CONCURRENCY
? Number(process.env.EMAIL_WORKER_CONCURRENCY)
: undefined;
// Upper bound for auto-derived concurrency. Raise this if you have a large SES
// quota AND have sized the Prisma connection pool accordingly.
export const EMAIL_WORKER_MAX_CONCURRENCY = process.env.EMAIL_WORKER_MAX_CONCURRENCY
? Number(process.env.EMAIL_WORKER_MAX_CONCURRENCY)
: 50;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
@@ -148,6 +128,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 % required to auto-disable project from a single detection
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%)
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)
-2
View File
@@ -64,7 +64,6 @@ 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;
@@ -75,7 +74,6 @@ export class Campaigns {
const result = await CampaignService.list(auth.projectId, {
status,
search,
page,
pageSize,
});
-2
View File
@@ -8,7 +8,6 @@ import {
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
MAIL_FROM_SUBDOMAIN,
NODE_ENV,
S3_ENABLED,
SMTP_DOMAIN,
@@ -63,7 +62,6 @@ export class Config {
},
aws: {
sesRegion: AWS_SES_REGION,
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
},
});
}
+73 -38
View File
@@ -1,8 +1,6 @@
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';
@@ -426,7 +424,31 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'subscribe');
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',
});
}
}
/**
@@ -437,7 +459,30 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'unsubscribe');
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',
});
}
}
/**
@@ -448,7 +493,30 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
return queueBulkAction(req, res, 'delete');
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',
});
}
}
/**
@@ -482,36 +550,3 @@ 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}`,
});
}
}
+24 -24
View File
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotAllowed, NotFound} from '../exceptions/index.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js';
@@ -17,12 +17,16 @@ export class Domains {
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(_req: Request, res: Response, _next: NextFunction) {
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params);
const domains = await DomainService.getProjectDomains(auth.projectId!);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains);
}
@@ -31,18 +35,19 @@ export class Domains {
* Add a new domain to a project
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {domain} = DomainSchemas.create.parse(req.body);
const projectId = auth.projectId!;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, projectId);
if (!auth.userId) {
throw new NotFound('User authentication required');
}
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(projectId);
if (isDisabled) {
@@ -63,12 +68,6 @@ export class Domains {
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) {
if (ownershipCheck.projectId === projectId) {
return res.status(400).json({
error: 'This domain is already linked to this project.',
});
}
// If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) {
return res.status(400).json({
@@ -100,7 +99,7 @@ export class Domains {
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -108,10 +107,13 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) {
if (!domain) {
throw new NotFound('Domain not found');
}
// Verify user has access to the project this domain belongs to
await MembershipService.requireAccess(auth.userId!, domain.projectId);
const verificationStatus = await DomainService.checkVerification(id);
// Invalidate cache if status changed
@@ -125,7 +127,7 @@ export class Domains {
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -133,14 +135,12 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain || domain.projectId !== auth.projectId) {
if (!domain) {
throw new NotFound('Domain not found');
}
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
}
// Verify user has admin access to the project this domain belongs to
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
-8
View File
@@ -230,14 +230,6 @@ 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,
},
+2 -26
View File
@@ -530,24 +530,10 @@ 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: creditBalance,
balance: -100,
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
@@ -573,16 +559,6 @@ export class Webhooks {
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Re-enable the project only if it was previously disabled for a failed payment.
// Projects disabled for other reasons (reputation, phishing, manual) must stay disabled.
if (project.disabled && project.disabledReason === 'PAYMENT_FAILED') {
await prisma.project.update({
where: {id: project.id},
data: {disabled: false, disabledReason: null},
});
signale.success(`[WEBHOOK] Project ${project.name} (${project.id}) re-enabled after payment`);
}
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
@@ -616,7 +592,7 @@ export class Webhooks {
await prisma.project.update({
where: {id: project.id},
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
data: {disabled: true},
});
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
-20
View File
@@ -149,26 +149,6 @@ 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
@@ -1,7 +1,6 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
import {coerceCustomValue} from '../import-processor.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
@@ -280,51 +279,3 @@ describe('Contact Import - Subscription Status Preservation', () => {
});
});
});
describe('coerceCustomValue', () => {
describe('boolean coercion', () => {
it.each(['true', 'TRUE', 'True', ' true ', 'yes', 'YES', 'Yes'])('coerces %j to true', value => {
expect(coerceCustomValue(value)).toBe(true);
});
it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => {
expect(coerceCustomValue(value)).toBe(false);
});
});
describe('number coercion', () => {
it.each([
['42', 42],
['-7', -7],
['3.14', 3.14],
[' 42 ', 42],
['0.5', 0.5],
['-0.25', -0.25],
['0', 0],
['1', 1],
])('coerces %j to %j', (value, expected) => {
expect(coerceCustomValue(value)).toBe(expected);
});
it.each(['01234', '+42', '.5', '42.', '1e10', 'NaN', 'Infinity', '1.2.3', '4-2'])(
'leaves %j as a string (preserves IDs / rejects loose formats)',
value => {
expect(coerceCustomValue(value)).toBe(value);
},
);
it('"1.0" is a number (does not match the boolean truthy set)', () => {
expect(coerceCustomValue('1.0')).toBe(1);
});
});
describe('passthrough', () => {
it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => {
expect(coerceCustomValue(value)).toBe(value);
});
it('leaves empty string as empty string', () => {
expect(coerceCustomValue('')).toBe('');
});
});
});
+47 -120
View File
@@ -3,93 +3,73 @@
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import {Prisma} from '@plunk/db';
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
import type {BulkContactActionJobData} 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;
const BATCH_SIZE = 100; // Process contacts in batches of 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, operation, selector} = job.data;
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const result: BulkActionResult = {
operation,
totalRequested: 0,
totalRequested: contactIds.length,
successCount: 0,
unchangedCount: 0,
failureCount: 0,
errors: [],
};
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}`,
);
try {
// Process contacts in batches
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
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;
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
}
} 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',
@@ -97,78 +77,25 @@ export function createBulkContactWorker() {
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
}
} 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}`,
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
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 {
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',
});
}
processedRows += batchIds.length;
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
if (batch.length < BATCH_SIZE) break;
}
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,
concurrency: 3, // Process max 3 bulk operations concurrently
},
);
+52 -38
View File
@@ -3,19 +3,13 @@
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {CampaignStatus, 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,
EMAIL_WORKER_CONCURRENCY,
EMAIL_WORKER_MAX_CONCURRENCY,
} from '../app/constants.js';
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';
@@ -52,31 +46,9 @@ async function getEmailRateLimit(): Promise<number> {
return DEFAULT_RATE_LIMIT;
}
/**
* Derive worker concurrency from the rate limit so a higher SES quota actually
* translates into higher throughput. The mean job duration is ~0.5s (Prisma
* reads + HTML compile + SES call + writes), so `rate * 0.5` gives ~2× headroom
* over the per-second cap. Clamped to keep sandbox accounts useful and to
* protect the Prisma pool on very large quotas.
*/
function deriveWorkerConcurrency(rateLimit: number): number {
if (EMAIL_WORKER_CONCURRENCY !== undefined) {
return EMAIL_WORKER_CONCURRENCY;
}
const TARGET_JOB_SECONDS = 0.5;
const MIN_CONCURRENCY = 5;
const derived = Math.ceil(rateLimit * TARGET_JOB_SECONDS);
return Math.max(MIN_CONCURRENCY, Math.min(derived, EMAIL_WORKER_MAX_CONCURRENCY));
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const concurrency = deriveWorkerConcurrency(rateLimit);
signale.info(
`[EMAIL-PROCESSOR] Worker concurrency: ${concurrency} (rate limit: ${rateLimit}/s)`,
);
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
@@ -110,12 +82,6 @@ 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;
}
@@ -260,8 +226,56 @@ 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) {
await CampaignService.finalizeIfDone(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,
);
}
}
}
} catch (error) {
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
@@ -280,7 +294,7 @@ export async function createEmailWorker() {
},
{
connection: emailQueue.opts.connection,
concurrency,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
+1 -32
View File
@@ -123,11 +123,7 @@ export function createImportWorker() {
// Extract custom data (all fields except email and subscribed)
const {email: _, subscribed: __, ...customData} = record;
const customEntries = Object.entries(customData);
const data =
customEntries.length > 0
? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)]))
: undefined;
const data = Object.keys(customData).length > 0 ? customData : undefined;
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email);
@@ -220,30 +216,3 @@ function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Values considered as boolean during import.
// Numbers (0, 1) are intentionally absent.
const BOOLEAN_TRUE = new Set(['true', 'yes']);
const BOOLEAN_FALSE = new Set(['false', 'no']);
// Strict integer-or-decimal number detection pattern.
// Valid: 0, 42, -42, 3.14
// Rejected: 007, +42, 1.2.3, 1e5
const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/;
/**
* Coerces a raw string into its most natural primitive type: `boolean`,
* `number`, or `string`. Values that match neither are
* returned unchanged.
*
* @param value The raw string to coerce.
* @returns The coerced value as `boolean`, `number`, or `string`.
*/
export function coerceCustomValue(value: string): string | boolean | number {
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
if (BOOLEAN_TRUE.has(lower)) return true;
if (BOOLEAN_FALSE.has(lower)) return false;
if (NUMERIC_RE.test(trimmed)) return Number(trimmed);
return value;
}
+1 -32
View File
@@ -222,34 +222,11 @@ 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};
@@ -268,15 +245,7 @@ export class ActivityService {
}),
]);
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;
return eventCount + emailCount + workflowCount;
}
/**
+32 -68
View File
@@ -1,5 +1,5 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
@@ -186,26 +186,16 @@ export class CampaignService {
projectId: string,
options: {
status?: CampaignStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<PaginatedResponse<Campaign>> {
const {status, search, page = 1, pageSize = 20} = options;
const {status, 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([
@@ -521,71 +511,45 @@ 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 = await prisma.email.count({where: {campaignId}});
const [actualEmailCount, alreadySentCount] = await Promise.all([
prisma.email.count({where: {campaignId}}),
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
]);
await prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: actualEmailCount},
});
await this.finalizeIfDone(campaignId);
// 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,
);
}
}
}
}
/**
* 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
*/
+112 -80
View File
@@ -135,47 +135,6 @@ 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,
@@ -190,14 +149,7 @@ export class ContactService {
updateData.email = data.email;
}
if (data.data !== undefined) {
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');
}
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
}
if (data.subscribed !== undefined) {
updateData.subscribed = data.subscribed;
@@ -273,7 +225,69 @@ export class ContactService {
},
});
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
// 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;
}
}
if (existing) {
// Track subscription status change
@@ -707,81 +721,99 @@ export class ContactService {
/**
* Bulk subscribe contacts
* 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).
* Updates multiple contacts to subscribed=true in batches
*/
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
// Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({
where: {id: {in: contactIds}, projectId},
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 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, unchanged};
return {updated: 0};
}
// 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, unchanged};
return {updated: result.count};
}
/**
* Bulk unsubscribe contacts.
* `updated` = contacts flipped from subscribed to unsubscribed.
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {id: {in: contactIds}, projectId},
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 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, unchanged};
return {updated: 0};
}
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, unchanged};
return {updated: result.count};
}
/**
+7 -18
View File
@@ -438,14 +438,15 @@ export class DomainService {
* @param userId User ID to check membership
* @returns Object with exists flag and membership info
*/
public static async checkDomainOwnership(domain: string, userId?: string) {
public static async checkDomainOwnership(domain: string, userId: string) {
const existingDomain = await prisma.domain.findFirst({
where: {domain},
include: {
project: {
select: {
id: true,
name: true,
include: {
members: {
where: {userId},
},
},
},
},
@@ -455,20 +456,8 @@ export class DomainService {
return {exists: false};
}
let isMember = false;
if (userId) {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: existingDomain.project.id,
},
},
});
isMember = membership !== null;
}
// Check if user is a member of the project that owns this domain
const isMember = existingDomain.project.members.length > 0;
return {
exists: true,
+19 -23
View File
@@ -108,7 +108,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
// Queue email for sending
await this.queueEmail(email.id, EmailSourceType.TRANSACTIONAL);
await this.queueEmail(email.id);
return email;
}
@@ -172,7 +172,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id, sourceType);
await this.queueEmail(email.id);
return email;
}
@@ -278,7 +278,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id, sourceType);
await this.queueEmail(email.id);
return email;
}
@@ -659,16 +659,12 @@ export class EmailService {
/**
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
*
* The TipTap editor loads StarterKit + TextAlign + Color + TextStyle + Link +
* ResizableImage + VariableMention. TextStyle/Color/Link round-trip <span style="..."> and
* <a style="..."> markup. This detection therefore PERMITS span + inline styles and only
* REJECTS markup TipTap cannot represent (tables, divs, forms, embeds, custom attrs,
* <style> blocks, etc).
*/
private static detectCustomHtmlPatterns(html: string): boolean {
if (!html || html.trim() === '') return false;
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
let hasCustomClasses = false;
for (const match of classMatches) {
@@ -683,21 +679,21 @@ export class EmailService {
}
}
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute.
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
// Elements TipTap cannot round-trip with the currently-loaded extension set.
// <span> is intentionally excluded -- TipTap's TextStyle extension handles it.
const hasCustomElements =
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
html,
);
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
}
/**
@@ -1137,7 +1133,7 @@ export class EmailService {
* Queue an email for sending
* Adds email to the BullMQ queue for processing by workers
*/
private static async queueEmail(emailId: string, sourceType: EmailSourceType, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, sourceType, delay);
private static async queueEmail(emailId: string, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, delay);
}
}
+6 -64
View File
@@ -1,11 +1,9 @@
import {CampaignStatus, EmailSourceType, 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,
@@ -174,43 +172,20 @@ export const meterQueue = new Queue<MeterEventJobData>('meter', {
},
});
function emailPriorityFor(sourceType: EmailSourceType): number {
switch (sourceType) {
case EmailSourceType.TRANSACTIONAL:
return 1;
case EmailSourceType.WORKFLOW:
return 5;
case EmailSourceType.CAMPAIGN:
return 10;
default:
return 5;
}
}
/**
* Queue Service - Centralized queue management
*/
export class QueueService {
/**
* Add email to queue for sending.
*
* Transactional emails jump the queue ahead of workflow and campaign sends
* via BullMQ's priority (lower number = higher precedence). This prevents
* latency-sensitive sends (login codes, password resets) from queuing behind
* large campaign bursts on the shared `email` queue.
* Add email to queue for sending
*/
public static async queueEmail(
emailId: string,
sourceType: EmailSourceType,
delay?: number,
): Promise<Job<SendEmailJobData>> {
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
return emailQueue.add(
'send-email',
{emailId},
{
delay,
jobId: `email-${emailId}`,
priority: emailPriorityFor(sourceType),
delay, // Optional delay in milliseconds
jobId: `email-${emailId}`, // Prevent duplicate jobs
},
);
}
@@ -374,12 +349,12 @@ export class QueueService {
*/
public static async queueBulkContactAction(
projectId: string,
selector: BulkContactActionSelector,
contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, operation, selector},
{projectId, contactIds, operation},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
@@ -603,39 +578,6 @@ 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}`);
}
+2 -6
View File
@@ -6,7 +6,6 @@ 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,
@@ -251,13 +250,10 @@ 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. 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.
// Set custom MAIL FROM domain (plunk.yourdomain.com)
await ses.setIdentityMailFromDomain({
Identity: domain,
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
MailFromDomain: `plunk.${domain}`,
});
return DKIM.DkimTokens ?? [];
+93 -91
View File
@@ -50,13 +50,22 @@ const SECURITY_THRESHOLDS = {
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
// === Absolute count ceilings (new projects only) ===
// These trigger regardless of rate — catches new accounts blasting emails
// before their bounce rate has caught up. Established projects rely on
// rate-based checks only, since high absolute counts at high volume
// (e.g. 100 bounces out of 10K) don't indicate abuse.
//
// Legitimate senders ramp up gradually; spammers blast immediately.
// === Absolute count ceilings ===
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
// 24-hour absolute ceilings
BOUNCE_24H_CEILING_WARNING: 50,
BOUNCE_24H_CEILING_CRITICAL: 100,
COMPLAINT_24H_CEILING_WARNING: 10,
COMPLAINT_24H_CEILING_CRITICAL: 25,
// 7-day absolute ceilings
BOUNCE_7DAY_CEILING_WARNING: 200,
BOUNCE_7DAY_CEILING_CRITICAL: 500,
COMPLAINT_7DAY_CEILING_WARNING: 30,
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
// === New project thresholds (projects < 30 days old) ===
// Legitimate senders ramp up gradually; spammers blast immediately
NEW_PROJECT_AGE_DAYS: 30,
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
@@ -507,54 +516,82 @@ export class SecurityService {
const violations: string[] = [];
const warnings: string[] = [];
// === Absolute count ceiling checks (new projects only, rate-independent) ===
// Catches new accounts blasting emails before their bounce rate catches up.
// Established projects skip these — high absolute counts at high volume
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them.
if (isNewProject) {
// 24-hour bounce ceilings
if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) {
violations.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`,
);
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) {
warnings.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`,
);
}
// Pick absolute count ceilings based on project age
const bounceCeilings = isNewProject
? {
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
}
: {
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
};
// 7-day bounce ceilings
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) {
violations.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`,
);
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) {
warnings.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`,
);
}
const complaintCeilings = isNewProject
? {
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL,
}
: {
ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL,
};
// 24-hour complaint ceilings
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) {
violations.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`,
);
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) {
warnings.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`,
);
}
const projectLabel = isNewProject ? ' (new project)' : '';
// 7-day complaint ceilings
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) {
violations.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`,
);
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) {
warnings.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`,
);
}
// === Absolute count ceiling checks (rate-independent) ===
// These catch high-volume spammers who dilute their bounce rate by blasting emails
// 24-hour bounce ceilings
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
violations.push(
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
);
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
warnings.push(
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
);
}
// 7-day bounce ceilings
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
violations.push(
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
);
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
warnings.push(
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
);
}
// 24-hour complaint ceilings
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
violations.push(
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
);
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
warnings.push(
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
);
}
// 7-day complaint ceilings
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
violations.push(
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
);
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
warnings.push(
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
);
}
// === Rate-based checks (existing logic) ===
@@ -676,7 +713,7 @@ export class SecurityService {
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
data: {disabled: true},
});
// Log critical security event
@@ -765,36 +802,7 @@ export class SecurityService {
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
// Extract sender domain for context
const senderDomain = (fromEmail.split('@')[1] ?? fromEmail).toLowerCase();
// Check whether this domain is verified by the project. A verified
// domain means the sender proved DNS/DKIM control — strong evidence of
// legitimacy, especially for institutional TLDs like .gov, .edu, .mil.
const verifiedDomain = await prisma.domain.findFirst({
where: {projectId, domain: senderDomain, verified: true},
select: {domain: true},
});
const isDomainVerified = verifiedDomain !== null;
// Institutional TLDs that imply a vetted, real-world entity behind the
// domain (government, military, accredited education). When combined
// with DKIM verification these effectively cannot be phishing senders.
const institutionalTldPattern =
/\.(gov|mil|edu)(\.[a-z]{2,})?$|\.gc\.ca$|\.gouv\.fr$|\.gov\.uk$|\.ac\.[a-z]{2,}$/i;
const isInstitutionalDomain = institutionalTldPattern.test(senderDomain);
// Skip the LLM check entirely when the sender is a verified institutional
// domain (e.g. a .gov customer). These TLDs are gated by registries that
// verify the real-world entity, and DKIM verification proves the project
// controls the domain — together they make phishing effectively
// impossible from this sender. Avoids paying for an LLM call that
// sometimes false-positives on official government communications.
if (isDomainVerified && isInstitutionalDomain) {
signale.info(
`[PHISHING] Skipping check for project ${projectId} — verified institutional domain (${senderDomain})`,
);
return safeResponse;
}
const senderDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : fromEmail;
// Call OpenRouter API
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
@@ -827,11 +835,7 @@ Criteria for phishing/dangerous content:
- Requests for sensitive personal information
IMPORTANT - Use sender and project context when evaluating:
- The sender project name and domain are provided, along with whether the domain has been verified (DKIM/DNS) by this project.
- A VERIFIED sender domain means the sender proved ownership of the domain via DNS records. This is strong evidence of legitimacy.
- If the verified sender domain is an institutional domain (e.g. .gov, .gov.uk, .gouv.fr, .gc.ca, .mil, .edu, .ac.*), treat the email as legitimate institutional communication. Government, military, and accredited education domains cannot be obtained by phishers — do NOT flag these as impersonation of government/banks/etc. just because the content mentions official topics, taxes, benefits, court notices, etc.
- Impersonation rules only apply when the sender domain does NOT match the brand being referenced. A verified bank domain sending a banking email is not impersonating itself.
- Links to the sender's own domain(s) are expected and NOT suspicious.
- The sender project name and domain are provided. Links to the sender's own domain(s) are expected and NOT suspicious.
- URLs that match or are clearly related to the project name or sender domain add credibility.
- Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand.
- Lack of recognizable brand does NOT make an email phishing — many legitimate businesses are not famous.
@@ -844,8 +848,6 @@ Set confidence to 100 only if you are absolutely certain it's phishing.`,
role: 'user',
content: `Sender project name: ${projectName}
Sender domain: ${senderDomain}
Sender domain verified (DKIM/DNS confirmed by project): ${isDomainVerified ? 'yes' : 'no'}
Sender domain is an institutional TLD (gov/mil/edu/ac/etc.): ${isInstitutionalDomain ? 'yes' : 'no'}
${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''}
Subject: ${subject}
@@ -969,7 +971,7 @@ ${strippedBody.substring(0, 2000)}`,
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
data: {disabled: true},
});
const violation = `A policy violation was detected. Please contact support for more details.`;
@@ -908,14 +908,7 @@ export class WorkflowExecutionService {
}
/**
* WEBHOOK step - Call an external webhook.
*
* Renders `{{vars}}` in `url`, header values, and `body`. The variable
* scope is a superset of the SEND_EMAIL scope: id, email, contact data,
* execution context, and subscribe/unsubscribe/manage URLs — plus a
* webhook-only `event` namespace exposing the trigger event payload.
* `method` is intentionally NOT rendered — it must remain a literal
* HTTP verb.
* WEBHOOK step - Call an external webhook
*/
private static async executeWebhook(
_step: WorkflowStep,
@@ -931,37 +924,9 @@ export class WorkflowExecutionService {
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
? (contact.data as Record<string, unknown>)
: {};
const executionContext =
execution.context && typeof execution.context === 'object' && !Array.isArray(execution.context)
? (execution.context as Record<string, unknown>)
: {};
const context = execution.context || {};
// Render scope: SEND_EMAIL's scope (id, email, contact data, execution
// context, subscribe/unsubscribe/manage URLs) plus a webhook-only
// `event` namespace carrying the trigger event payload. `method` is
// intentionally NOT rendered — it must remain a literal HTTP verb.
const variables = {
id: contact.id,
email: contact.email,
...contactData,
...executionContext,
data: contactData,
event: context,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
const renderedUrl = this.renderTemplate(url, variables);
const renderedHeaders = headers
? Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, this.renderTemplate(value, variables)]),
)
: undefined;
const renderedBody = body ? this.renderJsonTemplate(body, variables) : undefined;
const payload = renderedBody || {
const payload = body || {
contact: {
email: contact.email,
subscribed: contact.subscribed,
@@ -979,11 +944,11 @@ export class WorkflowExecutionService {
};
// Make HTTP request
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
const response = await WorkflowExecutionService.safeFetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...renderedHeaders,
...headers,
},
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
});
@@ -997,7 +962,7 @@ export class WorkflowExecutionService {
}
return {
url: renderedUrl,
url,
method,
statusCode: response.status,
success: response.ok,
@@ -1005,28 +970,6 @@ export class WorkflowExecutionService {
};
}
/**
* Helper: Recursively render template variables in any JSON-shaped value.
* Strings are rendered, arrays/objects are walked, and non-string scalars
* (numbers, booleans, null) are returned untouched.
*/
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
if (typeof value === 'string') {
return this.renderTemplate(value, variables);
}
if (Array.isArray(value)) {
return value.map(item => this.renderJsonTemplate(item, variables));
}
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
result[key] = this.renderJsonTemplate(child, variables);
}
return result;
}
return value;
}
/**
* UPDATE_CONTACT step - Update contact data
*/
@@ -1036,7 +979,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
const contact = execution.contact;
const currentData =
@@ -1044,43 +987,24 @@ export class WorkflowExecutionService {
? (contact.data as Record<string, unknown>)
: {};
const hasDataUpdates = updates && Object.keys(updates).length > 0;
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
// Merge updates with current data
const newData = {
...currentData,
...updates,
};
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,
);
}
// Update contact in database
await prisma.contact.update({
where: {id: contact.id},
data: {
data: newData ? toPrismaJson(newData) : undefined,
},
});
return {
updated: hasDataUpdates || subscriptionChanging,
updated: true,
updates,
newData,
subscriptionAction,
subscribed: desiredSubscribed ?? contact.subscribed,
};
}
-66
View File
@@ -310,72 +310,6 @@ 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
*/
@@ -50,20 +50,27 @@ describe('SecurityService', () => {
const complainedCount = opts?.complainedCount ?? 0;
const createdAt = opts?.createdAt ?? new Date();
const data = Array.from({length: count}, (_, i) => ({
projectId,
contactId,
subject: `Test ${i}`,
body: '<p>test</p>',
from: 'test@example.com',
status: EmailStatus.SENT,
sourceType: EmailSourceType.TRANSACTIONAL,
sentAt: createdAt,
createdAt,
bouncedAt: i < bouncedCount ? createdAt : null,
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
}));
await prisma.email.createMany({data});
const emails = [];
for (let i = 0; i < count; i++) {
emails.push(
prisma.email.create({
data: {
projectId,
contactId,
subject: `Test ${i}`,
body: '<p>test</p>',
from: 'test@example.com',
status: EmailStatus.SENT,
sourceType: EmailSourceType.TRANSACTIONAL,
sentAt: createdAt,
createdAt,
bouncedAt: i < bouncedCount ? createdAt : null,
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
},
}),
);
}
await Promise.all(emails);
}
describe('Rate-based checks (existing behavior)', () => {
@@ -102,12 +109,14 @@ describe('SecurityService', () => {
await createEmails(50, {bouncedCount: 10});
const status = await SecurityService.getSecurityStatus(projectId);
// Rate-based check doesn't trigger, but absolute count ceiling might
// With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects
expect(status.violations).toHaveLength(0);
});
});
describe('Established projects skip absolute ceilings', () => {
// Age the project past the new-project window
describe('Absolute count ceilings (established projects)', () => {
// Age the project past the new-project window so standard ceilings apply
beforeEach(async () => {
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
@@ -116,27 +125,45 @@ describe('SecurityService', () => {
});
});
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => {
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold)
// Established projects rely solely on rates — high absolute counts at
// high volume don't indicate abuse.
await createEmails(20000, {bouncedCount: 200});
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
// But 101 bounces > 100 (24h critical ceiling for established projects)
await createEmails(20000, {bouncedCount: 101});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
});
it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => {
// 10,000 emails, 51 bounces = 0.51% (below rate threshold)
// But 51 > 50 (24h warning ceiling), below 100 critical
await createEmails(10000, {bouncedCount: 51});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true);
});
it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => {
// 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%)
// But 26 > 25 (24h complaint critical ceiling)
await createEmails(20000, {complainedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true);
});
it('should NOT trigger ceiling when bounce count is below ceiling', async () => {
// 20,000 emails, 40 bounces = below 50 warning ceiling for established projects
await createEmails(20000, {bouncedCount: 40});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.shouldDisable).toBe(false);
expect(status.violations).toHaveLength(0);
expect(status.warnings).toHaveLength(0);
});
it('should NOT trigger on high absolute complaint count when rate is healthy', async () => {
// 100,000 emails, 30 complaints = 0.03% (at warning floor, below critical 0.15%)
// Old absolute ceiling (25 complaints in 7d critical) would have tripped.
await createEmails(100000, {complainedCount: 30});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(false);
});
});
describe('New project stricter thresholds', () => {
@@ -151,7 +178,7 @@ describe('SecurityService', () => {
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
});
it('should NOT apply absolute ceilings for projects over 30 days old', async () => {
it('should apply standard ceilings for projects over 30 days old', async () => {
// Age the project to 31 days
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
@@ -159,14 +186,15 @@ describe('SecurityService', () => {
data: {createdAt: oldDate},
});
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
// established projects skip ceilings entirely (rate is 0.26%, healthy).
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(false);
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false);
// 26 is below the 50-bounce 24h warning ceiling for established projects
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
// And below the 100-bounce 24h critical ceiling
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
});
it('should catch new project blasting emails with delayed bounces', async () => {
@@ -184,8 +212,8 @@ describe('SecurityService', () => {
describe('checkAndEnforceSecurityLimits', () => {
it('should disable project when critical thresholds are exceeded', async () => {
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
await createEmails(20000, {bouncedCount: 30});
// Create enough bounces to trigger critical
await createEmails(20000, {bouncedCount: 101});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -197,13 +225,13 @@ describe('SecurityService', () => {
});
it('should NOT disable project when only warnings exist', async () => {
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
// 10,000 emails, 51 bounces (above warning but below critical for established project)
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
await createEmails(200, {bouncedCount: 12});
await createEmails(10000, {bouncedCount: 51});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
@@ -1,229 +0,0 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Tests for WEBHOOK step config templating.
*
* `executeWebhook` is a private static method but is invokable at runtime
* through a `as any` cast. We mock `safeFetch` (also private) via the
* same mechanism so we can capture the rendered request without making a
* real network call.
*/
describe('WorkflowExecutionService.executeWebhook templating', () => {
let projectId: string;
const prisma = getPrismaClient();
// Capture (url, options) passed to safeFetch
let safeFetchSpy: ReturnType<typeof vi.spyOn>;
let captured: {url: string; options: RequestInit} | null = null;
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
captured = null;
safeFetchSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(WorkflowExecutionService as any, 'safeFetch')
.mockImplementation(async (...args: unknown[]) => {
const [url, options] = args as [string, RequestInit];
captured = {url, options};
return new Response('{"ok":true}', {
status: 200,
headers: {'Content-Type': 'application/json'},
});
});
});
afterEach(() => {
safeFetchSpy.mockRestore();
});
/**
* Helper: build a workflow with a single WEBHOOK step using the given
* config, plus a contact and a RUNNING execution. Returns the args
* shape `executeWebhook` expects.
*/
async function setup(
webhookConfig: Record<string, unknown>,
contactOverrides: {data?: Record<string, unknown>} = {},
executionContext: Record<string, unknown> = {},
) {
const contact = await factories.createContact({
projectId,
data: contactOverrides.data,
});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
const step = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.WEBHOOK,
name: 'Webhook',
position: {x: 0, y: 0},
config: webhookConfig,
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
context: executionContext,
},
include: {contact: true, workflow: true},
});
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: step.id,
status: StepExecutionStatus.RUNNING,
startedAt: new Date(),
},
});
return {step, execution, stepExecution};
}
async function invokeWebhook(
step: unknown,
execution: unknown,
stepExecution: unknown,
config: unknown,
) {
// Call through `as any` because executeWebhook is private at the
// TypeScript level. JS has no actual access control.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config);
}
it('renders {{vars}} in the URL from contact.data', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/api/users/{{userId}}',
method: 'GET',
},
{data: {userId: 'abc-123'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/api/users/abc-123');
});
it('renders {{vars}} in header values', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
headers: {
Authorization: 'Bearer {{apiToken}}',
'X-Static': 'literal',
},
},
{data: {apiToken: 'secret-token-xyz'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const headers = captured!.options.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer secret-token-xyz');
expect(headers['X-Static']).toBe('literal');
});
it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
body: {
user: {
email: '{{email}}',
name: '{{firstName}}',
},
ref: 'literal-ref',
tags: ['plan:{{plan}}', 'static'],
},
},
{data: {firstName: 'Ada', plan: 'gold'}},
{campaignId: 'camp-9'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.user.email).toBe(execution.contact.email);
expect(body.user.name).toBe('Ada');
expect(body.ref).toBe('literal-ref');
expect(body.tags).toEqual(['plan:gold', 'static']);
});
it('leaves non-string body leaves untouched', async () => {
const {step, execution, stepExecution} = await setup({
url: 'https://example.com/hook',
method: 'POST',
body: {
score: 42,
active: true,
deleted: null,
meta: {
count: 7,
enabled: false,
},
tags: ['{{plan ?? free}}', 100, false],
},
});
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.score).toBe(42);
expect(body.active).toBe(true);
expect(body.deleted).toBe(null);
expect(body.meta).toEqual({count: 7, enabled: false});
// String leaf rendered (with default), non-string leaves preserved.
expect(body.tags).toEqual(['free', 100, false]);
});
it('renders {{event.*}} variables from the trigger payload', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hooks/{{event.referrer}}',
method: 'POST',
headers: {
'X-Email-Id': '{{event.emailId}}',
},
body: {
referrer: '{{event.referrer}}',
subject: '{{event.subject}}',
},
},
{},
{referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/hooks/newsletter-may');
const headers = captured!.options.headers as Record<string, string>;
expect(headers['X-Email-Id']).toBe('eml_abc123');
const body = JSON.parse(captured!.options.body as string);
expect(body.referrer).toBe('newsletter-may');
expect(body.subject).toBe('Welcome');
});
});
-3
View File
@@ -53,9 +53,6 @@ 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 {
+1 -1
View File
@@ -44,7 +44,7 @@
"eslint": "^9.27.0",
"eslint-config-next": "^16.0.1",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
},
-78
View File
@@ -1,78 +0,0 @@
# 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 -32
View File
@@ -1,6 +1,5 @@
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';
@@ -8,10 +7,6 @@ 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'}>
@@ -92,25 +87,6 @@ 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>
@@ -268,15 +244,8 @@ export default function Footer() {
</div>
</div>
<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">
<div className="mt-16 border-t border-neutral-200 pt-8">
<p className="text-sm text-neutral-500">&copy; {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>
-114
View File
@@ -1,114 +0,0 @@
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>
);
}
-1
View File
@@ -4,4 +4,3 @@ export * from './ComparisonTable';
export * from './FAQSection';
export * from './CodeBlock';
export * from './SectionHeader';
export * from './SwitchOffer';
@@ -1,14 +0,0 @@
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);
}
-2
View File
@@ -341,5 +341,3 @@ 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
View File
@@ -1,8 +1,6 @@
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 }> {
@@ -28,7 +26,7 @@ function getQ(types: Array<{ type: string; q: number }>, target: string): number
function negotiate(accept: string): Negotiated {
if (!accept) return 'html';
const types = parseAccept(accept);
const mdQ = types.find(t => t.type === 'text/markdown')?.q ?? -1;
const mdQ = getQ(types, 'text/markdown');
const htmlQ = getQ(types, 'text/html');
if (mdQ <= 0 && htmlQ <= 0) return 'none';
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
@@ -61,9 +59,6 @@ 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;
}
-7
View File
@@ -2,11 +2,9 @@ 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';
@@ -39,10 +37,6 @@ 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');
@@ -57,7 +51,6 @@ 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'} />
+73 -74
View File
@@ -468,7 +468,7 @@ export default function Index() {
<Link
href={`/vs/${c.slug}`}
className={
'group flex items-center justify-between gap-6 py-5 transition-colors hover:bg-neutral-50 sm:py-7'
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
}
>
<div className={'flex items-center gap-6 sm:gap-10'}>
@@ -481,25 +481,28 @@ export default function Index() {
<span
style={{fontFamily: 'var(--font-display)'}}
className={
'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'
'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'
}
>
{c.name}
</span>
</div>
<div className={'flex items-center gap-5'}>
<div className={'flex items-center gap-4'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={
'hidden text-[11px] uppercase tracking-[0.18em] text-neutral-400 transition group-hover:text-neutral-900 sm:inline'
'hidden text-xs uppercase tracking-[0.18em] text-neutral-500 transition group-hover:text-neutral-900 sm:inline'
}
>
vs Plunk
</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}
/>
<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>
</div>
</Link>
</motion.li>
@@ -513,8 +516,8 @@ export default function Index() {
<SectionHeader
number={'03'}
label={'The Problem'}
title={'Simple to start. Serious at scale.'}
subtitle={'Easy enough for a side project. Ready for the business it becomes.'}
title={"Most email tools weren't built to scale."}
subtitle={'Three things Plunk gets right, out of the box.'}
/>
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-20 lg:gap-28'}>
@@ -577,7 +580,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:auto-rows-[17rem] lg:grid-cols-3'}>
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.feature;
return (
@@ -589,14 +592,12 @@ export default function Index() {
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? '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-900 bg-neutral-900 p-8 text-white'
: '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'}>
{highlighted ? <Workflow className="h-10 w-10" strokeWidth={1.25} /> : feature.icon}
</div>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${
@@ -609,20 +610,16 @@ export default function Index() {
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
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'
}
className={`mt-10 text-2xl font-bold tracking-[-0.025em] ${
highlighted ? 'text-white' : 'text-neutral-900'
}`}
>
{feature.title}
</h3>
<p
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'
}
className={`mt-3 text-sm leading-relaxed ${
highlighted ? 'text-neutral-300' : 'text-neutral-600'
}`}
>
{feature.description}
</p>
@@ -640,73 +637,60 @@ 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-3xl'}>
<div className={'grid gap-4 sm:grid-cols-2'}>
<div className={'mx-auto mt-20 max-w-5xl'}>
<div className={'grid gap-6 lg:grid-cols-3'}>
{[
{
icon: <Send className="h-5 w-5" strokeWidth={1.5} />,
icon: <Send className="h-7 w-7" strokeWidth={1.5} />,
title: 'Transactional',
sub: 'Receipts, resets',
},
{
icon: <Megaphone className="h-5 w-5" strokeWidth={1.5} />,
icon: <Megaphone className="h-7 w-7" strokeWidth={1.5} />,
title: 'Campaigns',
sub: 'Newsletters, launches',
},
{
icon: <Workflow className="h-5 w-5" strokeWidth={1.5} />,
icon: <Workflow className="h-7 w-7" 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.08, ease: [0.22, 1, 0.36, 1]}}
className={'flex items-center gap-4 rounded-2xl border border-neutral-200 bg-white p-5'}
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'}
>
<div
className={
'flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-100 text-neutral-900'
}
<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'}
>
{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>
{item.title}
</h3>
<p className={'mt-2 text-sm text-neutral-600'}>{item.sub}</p>
</motion.div>
))}
</div>
<div className={'relative my-10 hidden justify-center sm:flex'}>
<svg width="40" height="80" viewBox="0 0 40 80" aria-hidden>
<div className={'relative my-12 hidden lg:block'}>
<svg className={'mx-auto h-28 w-full'} viewBox="0 0 600 120" preserveAspectRatio="none">
<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 20 0 L 20 70"
d="M 100 0 Q 150 60, 250 110"
stroke="#d4d4d4"
strokeWidth="1.5"
fill="none"
@@ -714,7 +698,29 @@ export default function Index() {
initial={{pathLength: 0}}
whileInView={{pathLength: 1}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
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]}}
/>
</svg>
</div>
@@ -725,7 +731,6 @@ export default function Index() {
viewport={{once: true}}
transition={{duration: 0.8, delay: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-xl'}
data-nosnippet
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'flex items-center gap-5 p-6'}>
@@ -840,7 +845,7 @@ export default function Index() {
))}
</div>
<div className={'mt-10 flex justify-center'}>
<div className={'mt-12 flex justify-center'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
@@ -957,7 +962,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:auto-rows-[17rem] lg:grid-cols-3'}>
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{testimonials.map((t, i) => {
const highlighted = t.featured;
return (
@@ -969,7 +974,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-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-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-72 flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8'
}
>
@@ -977,34 +982,28 @@ export default function Index() {
style={highlighted ? {fontFamily: 'var(--font-display)'} : undefined}
className={
highlighted
? 'text-3xl font-medium leading-[1.1] tracking-[-0.02em] text-white sm:text-4xl lg:text-5xl'
? 'text-xl font-medium leading-[1.2] tracking-[-0.015em] text-white sm:text-2xl'
: 'text-sm leading-relaxed text-neutral-700'
}
>
&ldquo;{t.testimonial}&rdquo;
</blockquote>
<figcaption className={`mt-6 flex items-center ${highlighted ? 'gap-4' : 'gap-3'}`}>
<figcaption className={'mt-6 flex items-center gap-3'}>
<div
className={`relative overflow-hidden rounded-full ${
highlighted ? 'h-12 w-12 border border-neutral-700' : 'h-10 w-10'
className={`relative h-10 w-10 overflow-hidden rounded-full ${
highlighted ? 'border border-neutral-700' : ''
}`}
>
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
</div>
<div>
<div
className={
highlighted
? 'text-sm font-semibold text-white'
: 'text-xs font-semibold text-neutral-900'
}
>
<div className={`text-xs font-semibold ${highlighted ? 'text-white' : 'text-neutral-900'}`}>
{t.author}
</div>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={`mt-0.5 uppercase tracking-[0.18em] ${
highlighted ? 'text-[11px] text-neutral-400' : 'text-[10px] text-neutral-500'
className={`mt-0.5 text-[10px] uppercase tracking-[0.18em] ${
highlighted ? 'text-neutral-400' : 'text-neutral-500'
}`}
>
{t.role}
@@ -1,621 +0,0 @@
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 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
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&apos;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&apos;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 &amp; 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 />
</>
);
}
@@ -1,584 +0,0 @@
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 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
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&apos;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 &amp; 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 />
</>
);
}
@@ -1,655 +0,0 @@
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 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
Email 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&apos;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 />
</>
);
}
+6 -41
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, ArrowUpRight, Code2, FileText, Key, Mail, Search, Shield, ShieldAlert, ShieldCheck} from 'lucide-react';
import {ArrowRight, ArrowUpRight, Code2, Search, ShieldAlert} from 'lucide-react';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
@@ -50,54 +50,19 @@ 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 | 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."
title="Free Email Tools | Spam Checker, Email Validator & More | Plunk"
description="Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required."
canonical="https://www.useplunk.com/tools"
openGraph={{
title: 'Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk',
title: 'Free Email Tools | Spam Checker, Email Validator & 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.',
'Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
url: 'https://www.useplunk.com/tools',
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
}}
@@ -146,7 +111,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'}>
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.
Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
</p>
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
-462
View File
@@ -1,462 +0,0 @@
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 530 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 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
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 />
</>
);
}
@@ -1,587 +0,0 @@
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 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
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&apos;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 &amp; 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -170,8 +170,6 @@ 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'}>
-217
View File
@@ -1,217 +0,0 @@
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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -169,8 +169,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -193,8 +193,6 @@ 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'}>
-217
View File
@@ -1,217 +0,0 @@
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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -193,8 +193,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -189,8 +189,6 @@ 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'}>
+1 -20
View File
@@ -85,24 +85,6 @@ 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() {
@@ -200,11 +182,10 @@ 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 h-full 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 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -170,8 +170,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -193,8 +193,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -195,8 +195,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -170,8 +170,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -189,8 +189,6 @@ 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'}>
-217
View File
@@ -1,217 +0,0 @@
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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -190,8 +190,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -189,8 +189,6 @@ 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 -3
View File
@@ -1,4 +1,4 @@
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
@@ -193,8 +193,6 @@ 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'}>
+1 -1
View File
@@ -66,7 +66,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"next-sitemap": "^4.2.3",
"postcss": "^8.4.33",
"postcss": "^8.5.10",
"tailwindcss": "^4.1.17",
"typescript": "^5.7.2"
},
+18 -18
View File
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'event.triggered':
return {
icon: Zap,
color: 'text-amber-700',
bgColor: 'bg-amber-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
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-neutral-700',
bgColor: 'bg-neutral-100',
color: 'text-green-700',
bgColor: 'bg-green-50',
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-emerald-700',
bgColor: 'bg-emerald-50',
color: 'text-green-700',
bgColor: 'bg-green-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-emerald-700',
bgColor: 'bg-emerald-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
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-sky-700',
bgColor: 'bg-sky-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
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-amber-700',
bgColor: 'bg-amber-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
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-amber-700',
bgColor: 'bg-amber-50',
color: 'text-green-700',
bgColor: 'bg-green-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-sky-700',
bgColor: 'bg-sky-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
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-amber-700',
bgColor: 'bg-amber-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
description: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? `${String(metadata.subject)}` : ''}`
+8 -10
View File
@@ -335,8 +335,6 @@ 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">
@@ -496,8 +494,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</Badge>
</div>
<p className="text-xs text-neutral-600 mb-2">
Set up a custom MAIL FROM domain ({mailFromHost}) to improve deliverability and handle
bounces/complaints.
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
handle bounces/complaints.
</p>
<div className="overflow-x-auto">
@@ -524,16 +522,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">
{mailFromHost}
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(mailFromHost, 3000)}
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
>
<AnimatedCopyIcon
isCopied={copiedToken === `${mailFromHost}-3000`}
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
/>
</Button>
</div>
@@ -573,16 +571,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">
{mailFromHost}
plunk.{domain.domain}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyToken(mailFromHost, 3001)}
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
>
<AnimatedCopyIcon
isCopied={copiedToken === `${mailFromHost}-3001`}
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
/>
</Button>
</div>
@@ -306,31 +306,21 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
iframeDoc.write(fullHtml);
iframeDoc.close();
// 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).
// Auto-adjust iframe height to content
const adjustHeight = () => {
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`;
if (iframe.contentWindow) {
const height = iframe.contentWindow.document.body.scrollHeight;
iframe.style.height = `${Math.max(400, height + 40)}px`;
}
};
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);
};
// 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
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
+10 -10
View File
@@ -33,7 +33,7 @@ function HelpResources() {
};
return (
<div className="px-6 pb-6 pt-4 border-t border-neutral-200">
<div className="mt-4 pt-4 border-t border-neutral-200">
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
<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 className="flex flex-col h-full">
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Get started with Plunk in minutes</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
<CardContent>
<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 className="flex flex-col h-full">
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>Your project is fully set up</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
<CardContent>
<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 className="flex flex-col h-full">
<Card>
<CardHeader>
<CardTitle>Quick Start</CardTitle>
<CardDescription>
{visibleSteps.length === 0 ? 'Your project is set up' : 'Get started with Plunk in minutes'}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-y-auto">
<CardContent>
<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,7 +22,6 @@ 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);
@@ -30,7 +29,6 @@ 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>) => {
@@ -51,7 +49,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 ?? selectedName ?? initialName ?? '') : '');
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');
return (
<div className="relative">
@@ -87,7 +85,6 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
onSelect={() => {
onChange(t.id);
setQuery(t.name);
setSelectedName(t.name);
setOpen(false);
}}
>
+5 -15
View File
@@ -17,7 +17,6 @@ import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {
Clock,
ExternalLink,
GitBranch,
Hourglass,
Lightbulb,
@@ -244,7 +243,7 @@ function CustomNode({
bgColor?: string;
onEdit?: () => void;
onDelete?: () => void;
template?: {id: string; name: string};
template?: {name: string};
config?: any;
};
}) {
@@ -342,19 +341,10 @@ function CustomNode({
{/* Details */}
{data.template && (
<div className="mt-3 pt-3 border-t border-neutral-100">
<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 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>
</div>
)}
{data.type === 'DELAY' && data.config?.amount && (
@@ -1,5 +1,4 @@
import {Label, Select, SelectContent, SelectItemWithDescription, SelectTrigger, SelectValue, Input} from '@plunk/ui';
import {ExternalLink} from 'lucide-react';
import {useState} from 'react';
import {toast} from 'sonner';
@@ -69,20 +68,7 @@ export function SendEmailStepDialog({step, workflowId, open, onOpenChange, onSuc
>
<div className="space-y-4">
<div>
<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>
<Label htmlFor="editTemplate">Email Template</Label>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
</div>
@@ -1,4 +1,3 @@
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
import {useState} from 'react';
import {toast} from 'sonner';
@@ -6,50 +5,31 @@ 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();
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');
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
toast.error('At least one field to update is required');
return;
}
const ok = await update({
name,
config: {
updates: hasUpdates ? contactUpdateData : {},
subscriptionAction,
},
config: {updates: contactUpdateData},
});
if (ok) {
@@ -68,32 +48,7 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
>
<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>
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
</StepDialogShell>
);
}
@@ -1,126 +0,0 @@
import {describe, expect, it} from 'vitest';
import {detectCustomHtmlPatterns} from '../emailStyles';
describe('detectCustomHtmlPatterns', () => {
describe('empty / whitespace input', () => {
it('returns false for empty string', () => {
expect(detectCustomHtmlPatterns('')).toBe(false);
});
it('returns false for whitespace-only string', () => {
expect(detectCustomHtmlPatterns(' \n\t ')).toBe(false);
});
});
describe('content TipTap can round-trip (should NOT be flagged as custom)', () => {
it('returns false for a basic paragraph', () => {
expect(detectCustomHtmlPatterns('<p>Hello</p>')).toBe(false);
});
it('returns false for headings, lists, blockquote, bold, italic', () => {
expect(
detectCustomHtmlPatterns(
'<h1>Title</h1><p><strong>bold</strong> <em>italic</em></p><ul><li>one</li></ul><blockquote>quote</blockquote>',
),
).toBe(false);
});
it('returns false for <span> with inline color style (TipTap TextStyle output)', () => {
expect(detectCustomHtmlPatterns('<span style="color: rgb(220, 38, 38)">red</span>')).toBe(false);
});
it('returns false for <span> with background-color: initial (TipTap export artifact)', () => {
expect(detectCustomHtmlPatterns('<span style="background-color: initial">stuff</span>')).toBe(false);
});
it('returns false for <span> with background-color: transparent (TipTap export artifact)', () => {
expect(detectCustomHtmlPatterns('<span style="background-color: transparent">stuff</span>')).toBe(false);
});
it('returns false for <a> with inline style (TipTap Link output)', () => {
expect(detectCustomHtmlPatterns('<a href="https://example.com" style="color: red">link</a>')).toBe(false);
});
it('returns false for paragraph with inline text-align style', () => {
expect(detectCustomHtmlPatterns('<p style="text-align: center">centered</p>')).toBe(false);
});
it('returns false when an href URL contains "id=" or "contactId=" (must not match custom-attr regex)', () => {
expect(
detectCustomHtmlPatterns('<a href="https://example.com/u?contactId=abc&id=123">unsub</a>'),
).toBe(false);
});
it('returns false for allowed class prefixes', () => {
expect(detectCustomHtmlPatterns('<p class="prose">x</p>')).toBe(false);
expect(detectCustomHtmlPatterns('<span class="variable-mention">x</span>')).toBe(false);
expect(detectCustomHtmlPatterns('<img class="email-image" src="x" />')).toBe(false);
});
it('returns false for a TipTap-style colored span wrapped in a paragraph', () => {
expect(
detectCustomHtmlPatterns('<p>Hello <span style="color: rgb(220, 38, 38);">world</span>!</p>'),
).toBe(false);
});
});
describe('content TipTap can NOT round-trip (should be flagged as custom)', () => {
it('returns true for <div>', () => {
expect(detectCustomHtmlPatterns('<div>stuff</div>')).toBe(true);
});
it('returns true for <table> markup (no TipTap Table extension loaded)', () => {
expect(detectCustomHtmlPatterns('<table><tr><td>x</td></tr></table>')).toBe(true);
});
it('returns true for a single <table> tag', () => {
expect(detectCustomHtmlPatterns('<table>x</table>')).toBe(true);
});
it('returns true for <style> tag', () => {
expect(detectCustomHtmlPatterns('<style>p { color: red; }</style>')).toBe(true);
});
it('returns true for @media query inside a style block', () => {
expect(detectCustomHtmlPatterns('@media (max-width: 600px) { ... }')).toBe(true);
});
it('returns true for custom data-* attribute', () => {
expect(detectCustomHtmlPatterns('<p data-foo="bar">x</p>')).toBe(true);
});
it('returns true for aria-* attribute', () => {
expect(detectCustomHtmlPatterns('<p aria-label="x">y</p>')).toBe(true);
});
it('returns true for role= attribute', () => {
expect(detectCustomHtmlPatterns('<p role="presentation">x</p>')).toBe(true);
});
it('returns true for id= attribute on an element', () => {
expect(detectCustomHtmlPatterns('<p id="main">x</p>')).toBe(true);
});
it('returns true for a disallowed CSS class', () => {
expect(detectCustomHtmlPatterns('<p class="custom">x</p>')).toBe(true);
});
it('returns true for <section>, <article>, <header>, <footer>, <nav>, <aside>, <main>', () => {
expect(detectCustomHtmlPatterns('<section>x</section>')).toBe(true);
expect(detectCustomHtmlPatterns('<article>x</article>')).toBe(true);
expect(detectCustomHtmlPatterns('<header>x</header>')).toBe(true);
expect(detectCustomHtmlPatterns('<footer>x</footer>')).toBe(true);
expect(detectCustomHtmlPatterns('<nav>x</nav>')).toBe(true);
expect(detectCustomHtmlPatterns('<aside>x</aside>')).toBe(true);
expect(detectCustomHtmlPatterns('<main>x</main>')).toBe(true);
});
it('returns true for form/input/button/iframe/svg', () => {
expect(detectCustomHtmlPatterns('<form>x</form>')).toBe(true);
expect(detectCustomHtmlPatterns('<input type="text" />')).toBe(true);
expect(detectCustomHtmlPatterns('<button>x</button>')).toBe(true);
expect(detectCustomHtmlPatterns('<iframe src="x"></iframe>')).toBe(true);
expect(detectCustomHtmlPatterns('<svg><circle /></svg>')).toBe(true);
});
});
});
+14 -24
View File
@@ -1,15 +1,10 @@
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
//
// The TipTap editor in EmailEditor.tsx loads: StarterKit (paragraphs, headings, lists,
// blockquote, code, hr, bold, italic, strike, etc.), TextAlign, Color, TextStyle, Link,
// ResizableImage, and VariableMention. Of these, TextStyle + Color + Link natively
// round-trip <span style="color: ..."> / <a style="color: ..."> markup that TipTap itself
// generates when you change text color or style a link. We must therefore PERMIT what
// TipTap can represent and REJECT only what it can't.
export const detectCustomHtmlPatterns = (html: string): boolean => {
if (!html || html.trim() === '') return false;
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
let hasCustomClasses = false;
for (const match of classMatches) {
@@ -33,26 +28,21 @@ export const detectCustomHtmlPatterns = (html: string): boolean => {
}
}
// Custom attributes that carry semantics TipTap doesn't preserve. We require an
// attribute-boundary (whitespace, `=`, or quote) before the prefix so that query
// strings like `?id=...` inside an `href="..."` value don't false-match.
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
// Elements TipTap cannot round-trip with the currently-loaded extension set.
// - No Table/TableRow/TableCell extensions are loaded -> all table markup is custom.
// - No Div/Section/etc. block-layout extensions -> reject layout containers.
// - Form/embed/media/interactive elements have no TipTap representation here.
// <span> is intentionally NOT in this list: TipTap's TextStyle extension emits and
// accepts <span style="..."> for things like text color.
const hasCustomElements =
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
html,
);
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
};
export const wrapEmailWithStyles = (htmlBody: string): string => {
-1
View File
@@ -21,7 +21,6 @@ export interface ConfigResponse {
};
aws: {
sesRegion: string;
mailFromSubdomain: string;
};
}
+246 -357
View File
@@ -42,7 +42,6 @@ import {
ArrowLeft,
Calendar,
ChevronDown,
Info,
Mail,
MousePointer,
Save,
@@ -108,7 +107,6 @@ 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 =
@@ -180,7 +178,6 @@ 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');
@@ -439,35 +436,126 @@ export default function CampaignDetailsPage() {
</div>
</div>
{/* 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 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>
{/* 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>
)}
</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>
<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>
<CardTitle>Audience</CardTitle>
<CardDescription>Who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="audienceType">Audience Type *</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,
});
}}
@@ -483,7 +571,7 @@ export default function CampaignDetailsPage() {
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Specific Segment"
title="Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
@@ -491,10 +579,8 @@ export default function CampaignDetailsPage() {
</div>
{(editedCampaign.audienceType ?? c.audienceType) === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<div>
<Label htmlFor="segment">Select Segment *</Label>
<Select
value={editedCampaign.segmentId ?? c.segmentId ?? undefined}
onValueChange={(value: string) => {
@@ -524,160 +610,44 @@ export default function CampaignDetailsPage() {
</SelectContent>
</Select>
{segments && segments.length === 0 && (
<p className="text-sm text-neutral-500">
No segments found.{' '}
<Link href="/segments/new" className="underline">
Create one first
</Link>
</p>
<p className="text-xs text-neutral-500 mt-1">Create a segment first to use this option</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>
)}
{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>
{/* 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>
</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 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 */}
{/* Email Editor - Full Width */}
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email message</CardDescription>
<CardDescription>Design your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
@@ -692,51 +662,37 @@ export default function CampaignDetailsPage() {
{/* Test Email Dialog */}
<Dialog open={dialog.type === 'testEmail'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Send a preview</DialogTitle>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
Get a copy of this campaign in your inbox before sending it for real.
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.
</DialogDescription>
</DialogHeader>
<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 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>
</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"
@@ -753,8 +709,7 @@ export default function CampaignDetailsPage() {
onClick={handleSendTestEmail}
disabled={(dialog.type === 'testEmail' && dialog.sending) || !testEmailAddress}
>
<TestTube className="h-4 w-4" />
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send preview'}
{dialog.type === 'testEmail' && dialog.sending ? 'Sending...' : 'Send Test Email'}
</Button>
</DialogFooter>
</DialogContent>
@@ -762,105 +717,92 @@ export default function CampaignDetailsPage() {
{/* Schedule Dialog */}
<Dialog open={dialog.type === 'schedule'} onOpenChange={open => !open && setDialog({type: 'none'})}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Schedule for later</DialogTitle>
<DialogTitle>Schedule Campaign</DialogTitle>
<DialogDescription>
Pick a time and Plunk will send it for you. Times shown in {getUserTimezone()}.
Choose when you want this campaign to be sent (times shown in your local timezone: {getUserTimezone()}
)
</DialogDescription>
</DialogHeader>
<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 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>
</div>
{/* Custom Date/Time */}
<div className="space-y-2">
<Label htmlFor="scheduledDateTime">Or pick an exact time</Label>
<div>
<Label htmlFor="scheduledDateTime">Or choose a specific time</Label>
<Input
id="scheduledDateTime"
type="datetime-local"
value={scheduledDateTime}
onChange={e => {
setScheduledDateTime(e.target.value);
setSelectedPreset(null);
}}
onChange={e => setScheduledDateTime(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="mt-2"
/>
</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))}
{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))}
</p>
</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>
</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"
@@ -868,14 +810,12 @@ export default function CampaignDetailsPage() {
onClick={() => {
setDialog({type: 'none'});
setScheduledDateTime('');
setSelectedPreset(null);
}}
>
Not yet
Cancel
</Button>
<Button type="button" onClick={handleSchedule} disabled={!scheduledDateTime}>
<Calendar className="h-4 w-4" />
Schedule send
<Button type="button" onClick={handleSchedule}>
Schedule Campaign
</Button>
</DialogFooter>
</DialogContent>
@@ -885,66 +825,15 @@ export default function CampaignDetailsPage() {
{/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
<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 === '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"
/>
<ConfirmDialog
open={dialog.type === 'delete'}
+79 -102
View File
@@ -8,7 +8,11 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
@@ -19,11 +23,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, Search, Trash2, X} from 'lucide-react';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -31,9 +35,7 @@ import dayjs from 'dayjs';
export default function CampaignsPage() {
const router = useRouter();
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [statusFilter, setStatusFilter] = useState<'ALL' | 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'SENT' | 'CANCELLED'>('ALL');
const [statusFilter, setStatusFilter] = useState<string>('ALL');
const [showCancelDialog, setShowCancelDialog] = useState(false);
const [campaignToCancel, setCampaignToCancel] = useState<string | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -42,18 +44,10 @@ export default function CampaignsPage() {
const [showCampaignDialog, setShowCampaignDialog] = useState(false);
const {data, mutate, isLoading} = useSWR<PaginatedResponse<Campaign>>(
`/campaigns?page=${page}&pageSize=20${search ? `&search=${encodeURIComponent(search)}` : ''}${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`,
`/campaigns?page=${page}&pageSize=20${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'},
@@ -251,45 +245,21 @@ export default function CampaignsPage() {
</DropdownMenu>
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row sm:items-center 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 h-8 text-xs"
/>
{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>
{/* 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>
</div>
{/* Campaigns List */}
@@ -305,59 +275,66 @@ export default function CampaignsPage() {
<CardContent>
<EmptyState
icon={Mail}
title={search ? 'No campaigns match' : statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
title={statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
description={
search
? 'Try a different search term.'
: statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.'
statusFilter !== 'ALL'
? 'Adjust your filters or create a new campaign.'
: 'Send one-off emails to groups of contacts.'
}
action={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
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">
<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>
</Link>
</Button>
)
}
/>
</CardContent>
+180 -495
View File
@@ -2,6 +2,9 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Checkbox,
ConfirmDialog,
Dialog,
@@ -22,18 +25,14 @@ 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,
@@ -62,8 +61,6 @@ 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;
@@ -90,9 +87,6 @@ export default function ContactsPage() {
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
setSelectedContacts(new Set());
setSelectAllMatching(false);
setExcludedContacts(new Set());
}, 350);
return () => clearTimeout(timer);
}, [searchInput, search]);
@@ -102,12 +96,9 @@ export default function ContactsPage() {
const newPage = currentPage + 1;
setCursor(data.cursor);
setCurrentPage(newPage);
// 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());
}
setSelectedContacts(new Set()); // Clear selection on page change
// Store cursor in history if not already there
if (cursorHistory.length <= newPage) {
setCursorHistory(prev => [...prev, data.cursor]);
}
@@ -120,38 +111,12 @@ export default function ContactsPage() {
const previousCursor = cursorHistory[newPage];
setCursor(previousCursor);
setCurrentPage(newPage);
if (!selectAllMatching) {
setSelectedContacts(new Set());
}
setSelectedContacts(new Set()); // Clear selection on page change
}
};
// 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 (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) {
if (selectedContacts.size === contacts.length && contacts.length > 0) {
setSelectedContacts(new Set());
} else {
setSelectedContacts(new Set(contacts.map(c => c.id)));
@@ -159,30 +124,15 @@ export default function ContactsPage() {
};
const handleSelectContact = (contactId: string) => {
if (selectAllMatching) {
setExcludedContacts(prev => {
const next = new Set(prev);
if (next.has(contactId)) next.delete(contactId);
else next.add(contactId);
return next;
});
return;
const newSelected = new Set(selectedContacts);
if (newSelected.has(contactId)) {
newSelected.delete(contactId);
} else {
newSelected.add(contactId);
}
setSelectedContacts(prev => {
const next = new Set(prev);
if (next.has(contactId)) next.delete(contactId);
else next.add(contactId);
return next;
});
setSelectedContacts(newSelected);
};
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);
@@ -190,14 +140,6 @@ 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) => {
@@ -230,7 +172,8 @@ 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.
Manage your email subscribers and their data.{' '}
{totalCount > 0 ? `${totalCount.toLocaleString()} total contacts` : ''}
</p>
</div>
<div className="flex gap-2">
@@ -248,68 +191,45 @@ export default function ContactsPage() {
</div>
</div>
{/* 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'}
{/* 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
</span>
)}
</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">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleBulkAction('subscribe')}>
<MailCheck className="h-4 w-4 mr-1.5" />
Subscribe
@@ -318,50 +238,48 @@ export default function ContactsPage() {
<MailX className="h-4 w-4 mr-1.5" />
Unsubscribe
</Button>
<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"
>
<Button variant="outline" size="sm" onClick={() => handleBulkAction('delete')}>
<Trash2 className="h-4 w-4 mr-1.5" />
Delete
</Button>
</div>
</div>
<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 variant="ghost" size="sm" onClick={clearSelection}>
Clear Selection
</Button>
</div>
)}
</div>
<CardContent className="p-0">
</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>
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-16">
<div className="flex items-center justify-center py-12">
<IconSpinner />
</div>
) : contacts.length === 0 ? (
<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>
<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
}
/>
) : (
<>
{/* Desktop Table View - Hidden on mobile */}
@@ -371,7 +289,7 @@ export default function ContactsPage() {
<tr>
<th className="px-6 py-3 text-left w-12">
<Checkbox
checked={allOnPageSelected}
checked={selectedContacts.size === contacts.length && contacts.length > 0}
onCheckedChange={handleSelectAll}
/>
</th>
@@ -394,7 +312,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={isContactSelected(contact.id)}
checked={selectedContacts.has(contact.id)}
onCheckedChange={() => handleSelectContact(contact.id)}
/>
</td>
@@ -405,12 +323,7 @@ export default function ContactsPage() {
) : (
<MailX className="h-4 w-4 text-red-600" />
)}
<Link
href={`/contacts/${contact.id}`}
className="text-sm font-medium text-neutral-900 hover:text-neutral-700 focus-visible:outline-none focus-visible:underline"
>
{contact.email}
</Link>
<span className="text-sm font-medium text-neutral-900">{contact.email}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
@@ -447,7 +360,7 @@ export default function ContactsPage() {
</div>
{/* Mobile Card View - Only visible on mobile */}
<div className="md:hidden space-y-3 p-4">
<div className="md:hidden space-y-3">
{contacts.map(contact => (
<div
key={contact.id}
@@ -460,12 +373,7 @@ export default function ContactsPage() {
) : (
<MailX className="h-4 w-4 text-red-600 flex-shrink-0" />
)}
<Link
href={`/contacts/${contact.id}`}
className="text-sm font-medium text-neutral-900 truncate hover:text-neutral-700 focus-visible:outline-none focus-visible:underline"
>
{contact.email}
</Link>
<span className="text-sm font-medium text-neutral-900 truncate">{contact.email}</span>
</div>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
@@ -497,7 +405,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 px-6 py-4 border-t border-neutral-200">
<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="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>
@@ -547,12 +455,7 @@ export default function ContactsPage() {
open={showBulkActionsDialog}
onOpenChange={setShowBulkActionsDialog}
operation={bulkOperation}
selector={
selectAllMatching
? {mode: 'query', filter: search ? {search} : {}, excludeIds: Array.from(excludedContacts)}
: {mode: 'ids', contactIds: Array.from(selectedContacts)}
}
targetCount={effectiveSelectionCount}
contactIds={Array.from(selectedContacts)}
onSuccess={() => {
mutate();
clearSelection();
@@ -982,32 +885,23 @@ 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;
selector: BulkSelector;
targetCount: number;
contactIds: string[];
onSuccess: () => void;
}
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
operation: string;
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, selector, targetCount, onSuccess}: BulkActionsDialogProps) {
function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess}: BulkActionsDialogProps) {
const [, setJobId] = useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
@@ -1055,7 +949,8 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
}
if (response.result) {
toast.success(buildToastSummary(response.result));
const {successCount, failureCount} = response.result;
toast.success(`Completed: ${successCount} succeeded${failureCount > 0 ? `, ${failureCount} failed` : ''}`);
}
onSuccess();
@@ -1093,7 +988,7 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
const data = await network.fetch<{jobId: string; message: string}, typeof ContactSchemas.bulkAction>(
'POST',
endpoint,
selector,
{contactIds},
);
setJobId(data.jobId);
@@ -1124,97 +1019,103 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
onOpenChange(false);
};
const copy = getOperationCopy(operation);
const getOperationLabel = () => {
switch (operation) {
case 'subscribe':
return 'Subscribe';
case 'unsubscribe':
return 'Unsubscribe';
case 'delete':
return 'Delete';
default:
return 'Process';
}
};
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();
const getOperationColor = () => {
switch (operation) {
case 'subscribe':
return 'green';
case 'unsubscribe':
return 'yellow';
case 'delete':
return 'red';
default:
return 'blue';
}
};
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="transition-colors">{dialogTitle}</DialogTitle>
<DialogTitle>{getOperationLabel()} Contacts</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{status === 'idle' && (
<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>}
<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>?
</p>
{operation === 'delete' && (
<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>
<p className="text-xs text-red-500">This action cannot be undone.</p>
)}
</div>
)}
{status === 'processing' && (
<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 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="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 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 && <BulkResultSummary result={result} />}
{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</>
)}
</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>
)}
{status === 'failed' && (
<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 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>
)}
</div>
@@ -1231,25 +1132,16 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
disabled={isProcessing}
variant={operation === 'delete' ? 'destructive' : 'default'}
>
{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
{isProcessing ? 'Starting...' : getOperationLabel()}
</Button>
</>
) : status === 'completed' ? (
<Button type="button" onClick={handleClose}>
Close
</Button>
) : (
<Button
type="button"
onClick={handleClose}
variant={status === 'completed' ? 'default' : 'outline'}
>
{status === 'completed' ? 'Done' : 'Hide'}
<Button type="button" variant="outline" onClick={handleClose}>
Close
</Button>
)}
</DialogFooter>
@@ -1260,218 +1152,11 @@ function BulkActionsDialog({open, onOpenChange, operation, selector, targetCount
open={showCloseConfirmDialog}
onOpenChange={setShowCloseConfirmDialog}
onConfirm={confirmClose}
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"
title="Close Operation"
description="Operation is still in progress. Are you sure you want to close?"
confirmText="Close Anyway"
variant="destructive"
/>
</>
);
}
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>
);
}
+54 -504
View File
@@ -10,30 +10,10 @@ import {
CardTitle,
Skeleton,
} from '@plunk/ui';
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 {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useEffect, useMemo, useState} from 'react';
import useSWR from 'swr';
import {useState} from 'react';
import {ApiKeyDisplay} from '../components/ApiKeyDisplay';
import {DashboardLayout} from '../components/DashboardLayout';
import {QuickStart} from '../components/QuickStart';
@@ -48,195 +28,6 @@ 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();
@@ -250,129 +41,29 @@ 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,
value: totalContacts.toLocaleString(),
icon: Users,
format: (n: number) => n.toLocaleString(),
},
{
name: 'Emails Sent',
value: totalEmailsSent,
value: totalEmailsSent.toLocaleString(),
icon: Mail,
format: (n: number) => n.toLocaleString(),
},
{
name: 'Campaigns',
value: totalCampaigns.toLocaleString(),
icon: Send,
},
{
name: 'Open Rate',
value: openRate,
value: `${openRate.toFixed(1)}%`,
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('');
@@ -391,9 +82,6 @@ export default function Index() {
}
}
const recentItems = recentActivity?.data ?? [];
const liveCount = recentCount?.count ?? 0;
return (
<>
<NextSeo title="Dashboard" />
@@ -474,202 +162,64 @@ export default function Index() {
)}
{/* Header */}
<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>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{stats.map((stat, index) => {
{stats.map(stat => {
const Icon = stat.icon;
const isEmails = stat.name === 'Emails Sent';
const isOpenRate = stat.name === 'Open Rate';
return (
<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>
<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>
);
})}
{/* 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 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]'}`}>
{/* 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 */}
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
<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 whats 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 youll see it land here in real time.
</p>
</div>
{/* 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="space-y-0.5">
<AnimatePresence initial={false}>
{recentItems.map(activity => (
<CompactActivityRow key={activity.id} activity={activity} />
))}
</AnimatePresence>
</div>
<p className="text-sm text-neutral-500">No project selected</p>
)}
</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>
</CardContent>
</Card>
</div>
</div>
</DashboardLayout>
</>
+125 -133
View File
@@ -121,35 +121,57 @@ export default function TemplateEditorPage() {
return (
<DashboardLayout>
<NextSeo title={template.name} />
<div className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<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 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>
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* Row 1: Basic Info + Template Type */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
{/* Template Editor */}
<div className="space-y-6">
{/* Template Settings */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Name and describe your template</CardDescription>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure the basic settings for your template</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
@@ -160,7 +182,7 @@ export default function TemplateEditorPage() {
/>
</div>
<div className="space-y-2">
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
@@ -170,124 +192,94 @@ export default function TemplateEditorPage() {
placeholder="Sent to new subscribers"
/>
</div>
</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>
<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>
<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>
</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>
{/* 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>
<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>
{/* Sticky Save Bar */}
<StickySaveBar status={isSubmitting ? 'saving' : hasChanges ? 'dirty' : 'idle'} onSave={handleSave} />
+2 -2
View File
@@ -90,7 +90,7 @@ export default function TemplatesPage() {
</div>
{/* Search & Filters */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<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
@@ -98,7 +98,7 @@ export default function TemplatesPage() {
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10 h-8 text-xs"
className="pl-10 pr-10"
/>
{searchInput && (
<button
+3 -10
View File
@@ -206,18 +206,11 @@ export default function WorkflowEditorPage() {
}
break;
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`);
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`);
}
break;
}
}
});
+2 -20
View File
@@ -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, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
import {Calendar, 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,16 +66,6 @@ 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}`, {
@@ -117,7 +107,7 @@ export default function WorkflowsPage() {
placeholder="Search workflows..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10 h-8 text-xs"
className="pl-10 pr-10"
/>
{searchInput && (
<button
@@ -229,14 +219,6 @@ 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"
-9
View File
@@ -164,13 +164,4 @@
@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%);
}
}
-12
View File
@@ -26,13 +26,6 @@ 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>
@@ -64,11 +57,6 @@ 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}],
},
-13
View File
@@ -6,16 +6,3 @@
body {
font-family: 'Inter', sans-serif;
}
/*
* Two-tone palette: white content, gray chrome.
* `--color-fd-background` paints the page (content area + nav).
* `--color-fd-card` paints the sidebar (via `bg-fd-card` on `#nd-sidebar`)
* and the `<Cards>` component both read well as soft gray against white.
*/
:root {
--color-fd-background: hsl(0, 0%, 100%);
--color-fd-card: hsl(0, 0%, 96.5%);
--color-fd-secondary: hsl(0, 0%, 95%);
--color-fd-border: hsla(0, 0%, 80%, 60%);
}
-16
View File
@@ -1,16 +0,0 @@
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',
},
});
}
+36 -44
View File
@@ -27,18 +27,12 @@ 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. Also returned when email verification is required (`EMAIL_VERIFICATION_REQUIRED`) or the project has been disabled (`PROJECT_DISABLED`).
Not authorized to access this resource. Check permissions or project status.
### 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.
@@ -102,7 +96,6 @@ 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
@@ -449,56 +442,55 @@ if (!data.success) {
### Common Issues and Solutions
import {Accordion, Accordions} from 'fumadocs-ui/components/accordion';
#### Authentication Issues (401)
<Accordions type="single">
**Problem**: `INVALID_API_KEY` or `MISSING_AUTH`
<Accordion title="Authentication Issues (401) — INVALID_API_KEY or MISSING_AUTH">
**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
- 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.
#### Validation Issues (422)
</Accordion>
**Problem**: `VALIDATION_ERROR` with field errors
<Accordion title="Validation Issues (422) — VALIDATION_ERROR">
**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
- 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.
#### Not Found Issues (404)
</Accordion>
**Problem**: `TEMPLATE_NOT_FOUND`, `CONTACT_NOT_FOUND`, etc.
<Accordion title="Not Found Issues (404) — TEMPLATE_NOT_FOUND, CONTACT_NOT_FOUND, etc.">
**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
- 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.
#### Rate Limit Issues (429)
</Accordion>
**Problem**: `RATE_LIMIT_EXCEEDED`
<Accordion title="Rate Limit Issues (429) — RATE_LIMIT_EXCEEDED">
**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
- Implement exponential backoff (wait 1s, 2s, 4s, 8s between retries).
- Reduce request frequency.
- Consider upgrading your plan for higher limits.
- Batch operations when possible.
#### Server Errors (500)
</Accordion>
**Problem**: `INTERNAL_SERVER_ERROR`
<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>
**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
### Best Practices
+74 -193
View File
@@ -67,9 +67,11 @@ curl -X POST {{API_URL}}/contacts \
## Response format
### Success responses
All API responses follow a standardized format for easy parsing and error handling.
Public API endpoints (`/v1/send`, `/v1/track`, `/v1/verify`) return a wrapped envelope:
### Success response
Public API endpoints (`/v1/send`, `/v1/track`):
```json
{
@@ -82,24 +84,30 @@ Public API endpoints (`/v1/send`, `/v1/track`, `/v1/verify`) return a wrapped en
}
```
Dashboard endpoints (contacts, templates, campaigns, segments, workflows, etc.) return the resource directly — no `success`/`data` wrapper:
Dashboard API endpoints (contacts, templates, campaigns):
```json
{
"id": "cnt_abc123",
"email": "user@example.com",
"createdAt": "2025-11-30T10:30:00.000Z"
"success": true,
"data": {
"id": "cnt_abc123",
"email": "user@example.com",
"createdAt": "2025-11-30T10:30:00.000Z"
}
}
```
List endpoints with cursor pagination return:
List endpoints with pagination:
```json
{
"data": [ /* items */ ],
"cursor": "def456",
"hasMore": true,
"total": 10000
"success": true,
"data": {
"items": [...],
"nextCursor": "abc123",
"hasMore": true,
"total": 1000
}
}
```
@@ -140,37 +148,35 @@ See the [Error Codes documentation](/api-reference/errors) for complete details
## Pagination
Most list endpoints use **cursor-based** pagination:
List endpoints support cursor-based pagination:
```bash
GET /contacts?limit=100&cursor=abc123
```
**Parameters:**
- `limit` — items per page (default: 20, max: 100)
- `cursor` — pagination cursor from the previous response's `cursor` field
- `limit` — Number of items per page (default: 20, max: 100)
- `cursor` — Pagination cursor from previous response
**Response:**
```json
{
"data": [ /* items */ ],
"cursor": "def456",
"items": [...],
"nextCursor": "def456",
"hasMore": true,
"total": 10000
}
```
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.
Use `nextCursor` for the next page. When `hasMore` is false, you've reached the end.
## Rate limits
Plunk enforces reasonable rate limits to ensure service quality:
- **Email sending** — throttled per project to protect deliverability.
- **API requests** — 1000 requests/minute per project.
- **Bulk operations** — automatically queued for asynchronous processing.
- **Email sending** — 14 emails/second (AWS SES default)
- **API requests** — 1000 requests/minute per project
- **Bulk operations** — Automatically queued for processing
If you exceed limits, you'll receive a `429 Too Many Requests` response.
@@ -196,200 +202,75 @@ For a complete list of error codes and troubleshooting guidance, see the [Error
## API endpoints
A complete, grouped reference of every endpoint exposed by the Plunk API.
### Public API (transactional)
### Public API
**POST /v1/send** — Send transactional email(s)
- Accepts single or multiple recipients
- Template or inline content
- Variable substitution
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_*` |
**POST /v1/track** — Track event for contact
- Creates/updates contact
- Tracks custom event
- Can use public key
### Contacts
| 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. |
**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
### Templates
| 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. |
**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
### Campaigns
| 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 campaignreturns 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. |
**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
### Segments
| 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. |
**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)
### Workflows
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. |
**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
### Events
| 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. |
**GET /events** — List all events
**GET /events/names** — List unique event names
### Domains
| 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` |
**GET /domains** — List verified domains
**POST /domains** — Add domain for verification
**DELETE /domains/:id** — Remove domain
## Client libraries
+10 -80
View File
@@ -1,89 +1,19 @@
---
title: Billing
description: How Plunk's pricing, limits, and consumption work
description: Understand Plunk's billing model
icon: CreditCard
---
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.
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.
## Plans
## 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.
### Free tier
## 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 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.
You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings.
### 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>
## 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)
+7 -85
View File
@@ -1,93 +1,15 @@
---
title: Campaigns
description: One-off broadcast emails sent to a defined audience
description: Broadcast to your contacts
icon: Megaphone
---
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.
Campaigns are a one-time email sent to a group of contacts. They are typically used for newsletters, announcements, or promotions.
## Anatomy of a campaign
## 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.
A campaign captures everything needed to render and deliver one batch of emails:
## 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.
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>
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.
+26 -81
View File
@@ -10,12 +10,10 @@ 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) 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.
- 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
## Contact Data
@@ -23,37 +21,18 @@ You can associate custom data with each contact using key-value pairs. This data
### Data types
Contact data types are inferred from the **first non-null value** Plunk sees for a given key in your project:
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 |
| 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 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.
</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:
@@ -64,27 +43,20 @@ 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" type="info">
<Callout title="Removing contact data" variant="idea">
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
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)).
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 |
### Special keys
@@ -94,25 +66,15 @@ The last three are auto-generated per-recipient at send time and exposed as temp
## 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 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.
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default.
### 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 (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).
- **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
### Emails by subscription state
@@ -128,24 +90,7 @@ 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" type="warn">
<Callout title="Transactional emails and marketing templates" variant="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>
+26 -83
View File
@@ -6,108 +6,51 @@ 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 vs Static
## Dynamic segments
| | 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 |
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.
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.
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`, …)
## 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).
Conditions can be combined with `AND`/`OR` logic and nested into groups for complex rules.
## Static segments
Static segments are manually curated lists. Membership doesn't change automatically — you decide exactly who is in.
Static segments are manually curated lists. Membership does not change automatically — you decide exactly who is in the segment. This is useful for things like beta testers, event attendees, or any group imported from an external source.
### Creating
## Creating a segment
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.
Go to **Segments** in the dashboard and click **Create Segment**. Use the toggle at the top to choose **Dynamic** or **Static**.
### Adding and removing members
**For dynamic segments**, use the filter builder to define your conditions. Plunk will show you a live count of matching contacts.
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.
**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.
Programmatically, use:
## Managing static segment members
- `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 }`.
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.
Both endpoints **only** accept static segments. Calling them on a dynamic segment returns `400`.
To remove a member, click the remove button on their row in the members list.
Membership changes via these API calls are immediate but **do not** fire `entry`/`exit` events — those are reserved for dynamic, tracked segments.
## Track membership changes
## Tracking membership changes
Both segment types support **Track membership changes**. When enabled, Plunk fires a webhook event each time a contact enters or leaves the segment:
Dynamic segments can opt into **Track membership changes**. When enabled, Plunk fires events whenever a contact enters or leaves the segment:
- `segment.trial-users.entry` — contact joined the segment
- `segment.trial-users.exit` — contact left the segment
- `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.
Where `trial-users` is derived from the segment name. See [Webhooks](/guides/webhooks) for the full event payload.
## Using segments
Segments can be used to:
Segments can be used in:
- Targeting contacts in [email campaigns](/concepts/campaigns)
- Triggering workflows in [marketing automation](/concepts/workflows)
- 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)
## Managing members via API
## 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>
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.
+1 -49
View File
@@ -4,33 +4,7 @@ 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 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).
Templates are stored in the Plunk dashboard and can be used in campaigns, workflows and transactional emails.
## Designing templates
@@ -75,11 +49,6 @@ 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.
@@ -89,20 +58,3 @@ 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,24 +9,12 @@ 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.
The total message size cannot exceed 40 MB. Self-hosters can adjust the defaults — see [Environment variables](/self-hosting/environment-variables).
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 |
## 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.
## 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>
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.
+28 -87
View File
@@ -1,100 +1,41 @@
---
title: Workflows
description: Build automated, multi-step contact journeys triggered by events, segments, schedules, or manual entry
description: Set up automated email sequences and trigger them from your apps
icon: Workflow
---
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**.
Workflows in Plunk allow you to create automated email sequences that can be triggered based on events you send using the API.
## Triggers
## 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.
A workflow has a single **trigger** that decides how contacts enter:
### 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.
| 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). |
## Creating a workflow
To create a workflow, navigate to the [Workflows](/concepts/workflows) section of the dashboard and create a workflow.
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.
<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>
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.
### Defining workflow steps
Workflows consists of multiple steps that define the sequence of actions to be taken.
## Lifecycle and the `enabled` flag
| 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. |
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."
## 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.
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`, header values, and `body` support `{{variables}}`. | `url`, optional `method`, `headers`, `body` |
| `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>
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.
+10 -112
View File
@@ -1,121 +1,19 @@
---
title: API Keys
description: How Plunk's two-key model works and how to use, rotate, and revoke keys safely
description: Manage your API keys and understand their usage
icon: Key
---
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.
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.
## Where to find your keys
## 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.
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).
## 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.
## The two keys
If this key is compromised, a malicious actor could read and modify your project data, send emails, and perform other actions on your behalf.
| 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.
## 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.
@@ -1,135 +0,0 @@
---
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.

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