Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca06f56a6d | ||
|
|
80beb2bb99 | ||
|
|
6ab4d77ca9 | ||
|
|
edfc399061 | ||
|
|
4a145f3488 | ||
|
|
868bdee615 | ||
|
|
6ebbb50f68 | ||
|
|
87eb56ff36 | ||
|
|
a348d37c21 | ||
|
|
5c166797b5 | ||
|
|
971b98a4cc | ||
|
|
81315eac8e | ||
|
|
1c1c95d332 | ||
|
|
844be42151 | ||
|
|
d59f4a10cd | ||
|
|
51ac88b9f5 | ||
|
|
523bcad602 | ||
|
|
32dd7bba46 | ||
|
|
71e2277643 | ||
|
|
079e1879b3 | ||
|
|
4de40f40fa | ||
|
|
94ceadbbe4 | ||
|
|
9797aed47f | ||
|
|
8b3657d056 | ||
|
|
01ec34a8cb | ||
|
|
ba3813e242 | ||
|
|
283f40239d | ||
|
|
53b631e6c6 | ||
|
|
6759bffd2a | ||
|
|
d2496bc51d | ||
|
|
bfecf04fa3 | ||
|
|
6d98d51222 | ||
|
|
c484da88ab | ||
|
|
1d475c382b | ||
|
|
3fea06ba2d | ||
|
|
1d1d407a6f | ||
|
|
46b2b8390f | ||
|
|
9e4aa9443b | ||
|
|
a27d564e1a | ||
|
|
4e95c5a4e4 | ||
|
|
463301b5db | ||
|
|
f178c59b5b | ||
|
|
6195b39f3d | ||
|
|
e0bf0f628a | ||
|
|
8a26d605b4 | ||
|
|
715961c007 | ||
|
|
ccd516e4e8 | ||
|
|
6aee5db588 | ||
|
|
649bbf6d6b | ||
|
|
e43f70d8a1 | ||
|
|
f22da4add1 | ||
|
|
de6335e999 | ||
|
|
7658a59b5d | ||
|
|
aaf5ac6530 | ||
|
|
3f30a48c40 | ||
|
|
c6340a1dc7 | ||
|
|
4ba43dd3b6 | ||
|
|
d11495061d | ||
|
|
fadc19d139 | ||
|
|
2ea1802290 | ||
|
|
15bdcf6e49 | ||
|
|
2529c9b428 | ||
|
|
4ddafdc041 | ||
|
|
88be252a29 | ||
|
|
db5dfc46ce | ||
|
|
e2ce5d4e96 | ||
|
|
735acff454 | ||
|
|
1193d6c7e6 | ||
|
|
62661ae45f | ||
|
|
4587e9d670 | ||
|
|
88a8a02e53 | ||
|
|
cbb3bffcf4 | ||
|
|
80ef65e04b | ||
|
|
361ec0b1eb | ||
|
|
ed9027b4ef | ||
|
|
d1f357c300 | ||
|
|
a126563542 | ||
|
|
a3cc62213f | ||
|
|
2d05c3fbc1 | ||
|
|
c0752e1363 | ||
|
|
1b09ec1e13 | ||
|
|
0f00ca1b8c | ||
|
|
7615b62945 | ||
|
|
ba23a1c6fa | ||
|
|
4b5815c1d6 | ||
|
|
29883ffc71 | ||
|
|
5724ab9536 | ||
|
|
0c15cc84df | ||
|
|
a729789922 | ||
|
|
26ec5864ae | ||
|
|
48425d1df1 |
+13
-4
@@ -1,13 +1,22 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.turbo
|
||||
out
|
||||
# Patterns without a leading `**/` only match the build-context root, NOT
|
||||
# nested directories. In this monorepo the actual outputs live under
|
||||
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/`
|
||||
# any stale local builds (from running `yarn dev` or `yarn build` on the
|
||||
# host) get copied into the image and override the freshly-built outputs,
|
||||
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
|
||||
# bundle still references).
|
||||
**/dist
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/out
|
||||
|
||||
# Development
|
||||
.env
|
||||
|
||||
+30
-14
@@ -52,6 +52,13 @@ SES_CONFIGURATION_SET=plunk-configuration-set
|
||||
# When set, projects can choose to disable email tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
||||
|
||||
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
|
||||
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
# is already used for something else (e.g. an R2/CDN custom domain), since the
|
||||
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
|
||||
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
|
||||
# MAIL_FROM_SUBDOMAIN=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: OAuth Login
|
||||
# ========================================
|
||||
@@ -60,15 +67,6 @@ GITHUB_OAUTH_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Stripe Billing
|
||||
# ========================================
|
||||
STRIPE_SK=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING=
|
||||
STRIPE_PRICE_EMAIL_USAGE=
|
||||
STRIPE_METER_EVENT_NAME=emails
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: File Storage (Minio)
|
||||
# ========================================
|
||||
@@ -154,11 +152,29 @@ SMTP_DOMAIN=smtp.example.com
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Controls whether email validation checks are performed on signup
|
||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||
# When disabled (false), skips these validation checks and allows any email format
|
||||
# Default: false
|
||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||
# ========================================
|
||||
# OPTIONAL: SES Sending Rate
|
||||
# ========================================
|
||||
# Caps the email-worker's send rate (messages per second) for the SES sandbox or
|
||||
# manually-throttled accounts. When unset, the worker probes the AWS account at
|
||||
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
|
||||
# the worker silently falls back to 14/sec which may exceed sandbox limits and
|
||||
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
|
||||
# Default: unset (auto-detect, falls back to 14)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=1
|
||||
|
||||
# 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
|
||||
|
||||
# ========================================
|
||||
# ADVANCED (rarely needed)
|
||||
|
||||
@@ -65,6 +65,18 @@ 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,3 +1,3 @@
|
||||
{
|
||||
".": "0.10.0"
|
||||
".": "0.12.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,103 @@
|
||||
# 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)
|
||||
|
||||
|
||||
|
||||
@@ -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: 85) - Minimum confidence percentage (0-100) to auto-disable project for single detection
|
||||
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 95) - 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,6 +174,21 @@ 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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<h1 align="center">Plunk</h1>
|
||||
|
||||
@@ -15,23 +15,27 @@
|
||||
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
|
||||
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
|
||||
<a href="https://docs.useplunk.com"><img src="https://img.shields.io/badge/docs-docs.useplunk.com-black" alt="Documentation"/></a>
|
||||
<a href="https://useplunk.com/discord"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
|
||||
applications.
|
||||
It can be considered as a self-hosted alternative to services
|
||||
like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
|
||||
Transactional emails, marketing campaigns, and workflow automation — in one platform. Self-hostable, $0.001 per email, no contact limits.
|
||||
|
||||
An open-source, self-hosted alternative to tools [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
|
||||
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
|
||||
- **SMTP**: Use Plunk as an SMTP relay to send emails from any existing tool or framework
|
||||
- **Campaigns**: Send newsletters and product updates to large audiences
|
||||
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
|
||||
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
|
||||
- **Segments**: Organize contacts with dynamic filtering and target the right audience
|
||||
- **Contact Management**: Manage contacts with custom fields and full activity history
|
||||
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
|
||||
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
|
||||
- **Inbound Emails**: Receive and process incoming emails with custom routing rules
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -46,6 +50,11 @@ You can pull the latest image from [Github](https://github.com/useplunk/plunk/pk
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Community
|
||||
|
||||
- **Documentation**: [docs.useplunk.com](https://docs.useplunk.com)
|
||||
- **Discord**: [useplunk.com/discord](https://useplunk.com/discord)
|
||||
|
||||
## Contributing
|
||||
|
||||
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
# ==============================================================================
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
||||
# Port the API server listens on (default: 8080)
|
||||
# PORT=8080
|
||||
|
||||
# ==============================================================================
|
||||
# Application URLs
|
||||
@@ -25,6 +27,8 @@ 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
|
||||
@@ -61,6 +65,17 @@ 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)
|
||||
# ==============================================================================
|
||||
@@ -85,3 +100,46 @@ 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
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"mailparser": "^3.9.8",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/signale": "^1.4.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"tsx": "^4.20.6"
|
||||
|
||||
@@ -45,6 +45,12 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
|
||||
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
|
||||
// needs MX + TXT records that can't coexist with a CNAME.
|
||||
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
@@ -52,6 +58,20 @@ 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');
|
||||
@@ -128,6 +148,6 @@ export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', '');
|
||||
export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku');
|
||||
export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails
|
||||
export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== '';
|
||||
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%)
|
||||
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % required to auto-disable project from a single detection
|
||||
export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3)
|
||||
export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour)
|
||||
|
||||
@@ -64,6 +64,7 @@ export class Campaigns {
|
||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const status = req.query.status as CampaignStatus | undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
|
||||
@@ -74,6 +75,7 @@ export class Campaigns {
|
||||
|
||||
const result = await CampaignService.list(auth.projectId, {
|
||||
status,
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
NODE_ENV,
|
||||
S3_ENABLED,
|
||||
SMTP_DOMAIN,
|
||||
@@ -62,6 +63,7 @@ export class Config {
|
||||
},
|
||||
aws: {
|
||||
sesRegion: AWS_SES_REGION,
|
||||
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
import type {BulkContactActionSelector} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
@@ -424,31 +426,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk subscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'subscribe');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -459,30 +437,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk unsubscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'unsubscribe');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -493,30 +448,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk delete queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
||||
});
|
||||
}
|
||||
return queueBulkAction(req, res, 'delete');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -550,3 +482,36 @@ export class Contacts {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function queueBulkAction(
|
||||
req: Request,
|
||||
res: Response,
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({
|
||||
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
|
||||
});
|
||||
}
|
||||
|
||||
const selector: BulkContactActionSelector =
|
||||
parsed.data.mode === 'ids'
|
||||
? {mode: 'ids', contactIds: parsed.data.contactIds}
|
||||
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
|
||||
return res.status(202).json({
|
||||
message: `Bulk ${operation} queued successfully`,
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
@@ -17,16 +17,12 @@ export class Domains {
|
||||
* Get all domains for a project
|
||||
*/
|
||||
@Get('project/:projectId')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, 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);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
const domains = await DomainService.getProjectDomains(auth.projectId!);
|
||||
|
||||
return res.status(200).json(domains);
|
||||
}
|
||||
@@ -35,19 +31,18 @@ export class Domains {
|
||||
* Add a new domain to a project
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||
const {domain} = DomainSchemas.create.parse(req.body);
|
||||
const projectId = auth.projectId!;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotFound('User authentication required');
|
||||
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||
if (auth.type === 'jwt') {
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -68,6 +63,12 @@ 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({
|
||||
@@ -99,7 +100,7 @@ export class Domains {
|
||||
* Check verification status for a domain
|
||||
*/
|
||||
@Get(':id/verify')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
@@ -107,13 +108,10 @@ export class Domains {
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
if (!domain || domain.projectId !== auth.projectId) {
|
||||
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
|
||||
@@ -127,7 +125,7 @@ export class Domains {
|
||||
* Remove a domain from a project
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
@@ -135,12 +133,14 @@ export class Domains {
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
if (!domain || domain.projectId !== auth.projectId) {
|
||||
throw new NotFound('Domain not found');
|
||||
}
|
||||
|
||||
// Verify user has admin access to the project this domain belongs to
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
// Require admin role for JWT users (API keys bypass — project-scoped by design)
|
||||
if (auth.type === 'jwt') {
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
}
|
||||
|
||||
// Block domain changes on disabled projects
|
||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
||||
|
||||
@@ -230,6 +230,14 @@ export class Users {
|
||||
client_reference_id: project.id, // Store project ID for webhook
|
||||
line_items: lineItems,
|
||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||
custom_fields: [
|
||||
{
|
||||
key: 'promo_code',
|
||||
label: {type: 'custom', custom: 'Promo code'},
|
||||
type: 'text',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: billingCycleAnchor,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {Request, Response} from 'express';
|
||||
import {simpleParser} from 'mailparser';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -157,15 +158,33 @@ export class Webhooks {
|
||||
// Parse email content if available
|
||||
let htmlBody: string | undefined;
|
||||
|
||||
if (body.content) {
|
||||
if (body.content && typeof body.content === 'string') {
|
||||
try {
|
||||
const parsed = await simpleParser(body.content);
|
||||
// Prefer HTML body, fallback to text if no HTML available
|
||||
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
|
||||
signale.info('[WEBHOOK] Email content parsed successfully');
|
||||
const isBase64 = body.receipt?.action?.encoding === 'BASE64';
|
||||
const emailBuffer = isBase64
|
||||
? Buffer.from(body.content, 'base64')
|
||||
: Buffer.from(body.content);
|
||||
|
||||
const parsed = await simpleParser(emailBuffer);
|
||||
const raw =
|
||||
(parsed.html ? String(parsed.html) : undefined) ??
|
||||
parsed.textAsHtml ??
|
||||
parsed.text ??
|
||||
undefined;
|
||||
|
||||
if (raw) {
|
||||
htmlBody = sanitizeHtml(raw, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
|
||||
// Continue processing without content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,10 +530,24 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||
let creditBalance = -100;
|
||||
|
||||
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
|
||||
// into the Promo code custom field on Stripe Checkout.
|
||||
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
|
||||
const promoCode = promoField?.text?.value?.trim().toUpperCase();
|
||||
if (promoCode === 'SWITCH') {
|
||||
creditBalance -= 200;
|
||||
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
|
||||
} else if (promoCode) {
|
||||
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Update Stripe customer name to match project name and add credit for onboarding fee
|
||||
await stripe.customers.update(customerId, {
|
||||
name: updatedProject.name,
|
||||
balance: -100,
|
||||
balance: creditBalance,
|
||||
});
|
||||
|
||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||
@@ -540,6 +573,16 @@ 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;
|
||||
@@ -573,7 +616,7 @@ export class Webhooks {
|
||||
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {disabled: true},
|
||||
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
|
||||
});
|
||||
|
||||
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||
@@ -652,6 +695,86 @@ export class Webhooks {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'radar.early_fraud_warning.created': {
|
||||
const warning = event.data.object as Stripe.Radar.EarlyFraudWarning;
|
||||
const chargeId = typeof warning.charge === 'string' ? warning.charge : warning.charge?.id;
|
||||
|
||||
if (!chargeId) {
|
||||
signale.warn('[WEBHOOK] radar.early_fraud_warning.created missing charge ID');
|
||||
break;
|
||||
}
|
||||
|
||||
signale.warn(`[WEBHOOK] Early fraud warning received for charge ${chargeId} (${warning.fraud_type})`);
|
||||
|
||||
// Retrieve the charge to get card fingerprint and customer email
|
||||
const charge = await stripe.charges.retrieve(chargeId, {
|
||||
expand: ['payment_method_details', 'billing_details'],
|
||||
});
|
||||
|
||||
const cardFingerprint = charge.payment_method_details?.card?.fingerprint ?? null;
|
||||
const customerEmail = charge.billing_details?.email ?? null;
|
||||
|
||||
// Refund the charge
|
||||
try {
|
||||
await stripe.refunds.create({charge: chargeId});
|
||||
signale.success(`[WEBHOOK] Refunded charge ${chargeId} due to early fraud warning`);
|
||||
} catch (refundError) {
|
||||
signale.error(`[WEBHOOK] Failed to refund charge ${chargeId}:`, refundError);
|
||||
}
|
||||
|
||||
// Add card fingerprint and email to Stripe Radar blocklist value lists
|
||||
if (cardFingerprint) {
|
||||
try {
|
||||
const lists = await stripe.radar.valueLists.list({alias: 'blocked_card_fingerprints'});
|
||||
let listId: string;
|
||||
|
||||
const existingList = lists.data[0];
|
||||
if (existingList) {
|
||||
listId = existingList.id;
|
||||
} else {
|
||||
const newList = await stripe.radar.valueLists.create({
|
||||
alias: 'blocked_card_fingerprints',
|
||||
name: 'Blocked Card Fingerprints',
|
||||
item_type: 'card_fingerprint',
|
||||
});
|
||||
listId = newList.id;
|
||||
}
|
||||
|
||||
await stripe.radar.valueListItems.create({value_list: listId, value: cardFingerprint});
|
||||
signale.success(`[WEBHOOK] Added card fingerprint ${cardFingerprint} to Radar blocklist`);
|
||||
} catch (blocklistError) {
|
||||
signale.error(`[WEBHOOK] Failed to add card fingerprint to Radar blocklist:`, blocklistError);
|
||||
}
|
||||
}
|
||||
|
||||
if (customerEmail) {
|
||||
try {
|
||||
const emailLists = await stripe.radar.valueLists.list({alias: 'blocked_emails'});
|
||||
let emailListId: string;
|
||||
|
||||
const existingEmailList = emailLists.data[0];
|
||||
if (existingEmailList) {
|
||||
emailListId = existingEmailList.id;
|
||||
} else {
|
||||
const newList = await stripe.radar.valueLists.create({
|
||||
alias: 'blocked_emails',
|
||||
name: 'Blocked Emails',
|
||||
item_type: 'email',
|
||||
});
|
||||
emailListId = newList.id;
|
||||
}
|
||||
|
||||
await stripe.radar.valueListItems.create({value_list: emailListId, value: customerEmail});
|
||||
signale.success(`[WEBHOOK] Added email ${customerEmail} to Radar blocklist`);
|
||||
} catch (blocklistError) {
|
||||
signale.error(`[WEBHOOK] Failed to add email to Radar blocklist:`, blocklistError);
|
||||
}
|
||||
}
|
||||
|
||||
await NtfyService.notifyEarlyFraudWarning(chargeId, warning.fraud_type, cardFingerprint, customerEmail);
|
||||
break;
|
||||
}
|
||||
|
||||
// Unhandled events
|
||||
default:
|
||||
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
|
||||
|
||||
@@ -149,6 +149,26 @@ export class Workflows {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/duplicate
|
||||
* Duplicate a workflow (always disabled, no execution state)
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
|
||||
|
||||
return res.status(201).json(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/steps
|
||||
* Add a step to a workflow
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -279,3 +280,51 @@ 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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,73 +3,149 @@
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface BulkActionResult {
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
totalRequested: number;
|
||||
/** Contacts whose state was actually changed by this run. */
|
||||
successCount: number;
|
||||
/** Subscribe/unsubscribe only: contacts already in the target state. */
|
||||
unchangedCount: number;
|
||||
/** Contacts that errored or weren't found (e.g. wrong project). */
|
||||
failureCount: number;
|
||||
errors: {contactId: string; email: string; error: string}[];
|
||||
}
|
||||
|
||||
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
|
||||
const search = selector.filter?.search;
|
||||
const excludeIds = selector.excludeIds ?? [];
|
||||
return {
|
||||
projectId,
|
||||
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
|
||||
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function applyBatch(
|
||||
projectId: string,
|
||||
operation: BulkActionResult['operation'],
|
||||
ids: string[],
|
||||
): Promise<{changed: number; unchanged: number}> {
|
||||
switch (operation) {
|
||||
case 'subscribe': {
|
||||
const r = await ContactService.bulkSubscribe(projectId, ids);
|
||||
return {changed: r.updated, unchanged: r.unchanged};
|
||||
}
|
||||
case 'unsubscribe': {
|
||||
const r = await ContactService.bulkUnsubscribe(projectId, ids);
|
||||
return {changed: r.updated, unchanged: r.unchanged};
|
||||
}
|
||||
case 'delete': {
|
||||
const r = await ContactService.bulkDelete(projectId, ids);
|
||||
return {changed: r.deleted, unchanged: 0};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBulkContactWorker() {
|
||||
const worker = new Worker<BulkContactActionJobData>(
|
||||
bulkContactQueue.name,
|
||||
async (job: Job<BulkContactActionJobData>) => {
|
||||
const {projectId, contactIds, operation} = job.data;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
||||
);
|
||||
const {projectId, operation, selector} = job.data;
|
||||
|
||||
const result: BulkActionResult = {
|
||||
operation,
|
||||
totalRequested: contactIds.length,
|
||||
totalRequested: 0,
|
||||
successCount: 0,
|
||||
unchangedCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Process contacts in batches
|
||||
if (selector.mode === 'ids') {
|
||||
const {contactIds} = selector;
|
||||
result.totalRequested = contactIds.length;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
|
||||
);
|
||||
|
||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
||||
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
|
||||
try {
|
||||
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||
result.successCount += changed;
|
||||
result.unchangedCount += unchanged;
|
||||
const failed = batchIds.length - changed - unchanged;
|
||||
if (failed > 0) result.failureCount += failed;
|
||||
} catch (error) {
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
email: '',
|
||||
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||
});
|
||||
}
|
||||
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
|
||||
}
|
||||
} else {
|
||||
const where = buildQueryWhere(projectId, selector);
|
||||
const total = await prisma.contact.count({where});
|
||||
result.totalRequested = total;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
|
||||
);
|
||||
|
||||
if (total === 0) {
|
||||
await job.updateProgress(100);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
|
||||
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
|
||||
// the rows we just processed disappear — a stable cursor would either skip survivors
|
||||
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
|
||||
let lastId: string | undefined;
|
||||
let processedRows = 0;
|
||||
|
||||
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
|
||||
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
|
||||
for (let iter = 0; iter < maxIterations; iter += 1) {
|
||||
const batch = await prisma.contact.findMany({
|
||||
where: {
|
||||
...where,
|
||||
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
|
||||
},
|
||||
select: {id: true},
|
||||
orderBy: {id: 'desc'},
|
||||
take: BATCH_SIZE,
|
||||
});
|
||||
|
||||
if (batch.length === 0) break;
|
||||
|
||||
const batchIds = batch.map(c => c.id);
|
||||
lastId = batchIds[batchIds.length - 1];
|
||||
|
||||
try {
|
||||
let batchResult: {updated?: number; deleted?: number};
|
||||
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'delete':
|
||||
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
||||
result.successCount += batchResult.deleted || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// If some contacts in batch weren't processed, track them as failures
|
||||
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
||||
const failedCount = batchIds.length - processedCount;
|
||||
if (failedCount > 0) {
|
||||
result.failureCount += failedCount;
|
||||
// Note: We don't have individual contact details for batch failures
|
||||
}
|
||||
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
|
||||
result.successCount += changed;
|
||||
result.unchangedCount += unchanged;
|
||||
const failed = batchIds.length - changed - unchanged;
|
||||
if (failed > 0) result.failureCount += failed;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
processedRows += batchIds.length;
|
||||
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
|
||||
|
||||
if (batch.length < BATCH_SIZE) break;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
{
|
||||
connection: bulkContactQueue.opts.connection,
|
||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
||||
concurrency: 3,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||
*/
|
||||
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
EMAIL_RATE_LIMIT_PER_SECOND,
|
||||
EMAIL_WORKER_CONCURRENCY,
|
||||
EMAIL_WORKER_MAX_CONCURRENCY,
|
||||
} 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';
|
||||
@@ -46,9 +52,31 @@ 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>) => {
|
||||
@@ -82,6 +110,12 @@ export async function createEmailWorker() {
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
|
||||
// Cancelled emails are terminal for the campaign — finalize so it doesn't
|
||||
// stay stuck in SENDING forever waiting on emails that will never be sent.
|
||||
if (email.campaignId) {
|
||||
await CampaignService.finalizeIfDone(email.campaignId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -226,56 +260,8 @@ export async function createEmailWorker() {
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
await CampaignService.finalizeIfDone(email.campaignId);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
@@ -294,7 +280,7 @@ export async function createEmailWorker() {
|
||||
},
|
||||
{
|
||||
connection: emailQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 emails concurrently
|
||||
concurrency,
|
||||
limiter: {
|
||||
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||
duration: 1000,
|
||||
|
||||
@@ -123,7 +123,11 @@ export function createImportWorker() {
|
||||
|
||||
// Extract custom data (all fields except email and subscribed)
|
||||
const {email: _, subscribed: __, ...customData} = record;
|
||||
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
||||
const customEntries = Object.entries(customData);
|
||||
const data =
|
||||
customEntries.length > 0
|
||||
? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)]))
|
||||
: undefined;
|
||||
|
||||
// Check if contact exists before upserting
|
||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||
@@ -216,3 +220,30 @@ 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;
|
||||
}
|
||||
|
||||
@@ -222,11 +222,34 @@ export class ActivityService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Short Redis TTL for recent-count results. Short enough to feel live on the
|
||||
* dashboard live-pulse, long enough to absorb the polling load when many
|
||||
* tabs are open across the user base.
|
||||
*/
|
||||
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
|
||||
|
||||
/**
|
||||
* Get recent activity count (for real-time updates)
|
||||
* Returns count of activities in the last N minutes
|
||||
*
|
||||
* Backed by a short Redis cache because the dashboard polls this endpoint
|
||||
* every 30 seconds per open tab; without the cache, every poll would run
|
||||
* three COUNT queries against the events, emails, and workflow_executions
|
||||
* tables.
|
||||
*/
|
||||
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return parseInt(cached, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
||||
|
||||
@@ -245,7 +268,15 @@ export class ActivityService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return eventCount + emailCount + workflowCount;
|
||||
const total = eventCount + emailCount + workflowCount;
|
||||
|
||||
try {
|
||||
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
@@ -186,16 +186,26 @@ export class CampaignService {
|
||||
projectId: string,
|
||||
options: {
|
||||
status?: CampaignStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<PaginatedResponse<Campaign>> {
|
||||
const {status, page = 1, pageSize = 20} = options;
|
||||
const {status, search, page = 1, pageSize = 20} = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.CampaignWhereInput = {
|
||||
projectId,
|
||||
...(status ? {status} : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{name: {contains: search, mode: 'insensitive' as const}},
|
||||
{subject: {contains: search, mode: 'insensitive' as const}},
|
||||
{from: {contains: search, mode: 'insensitive' as const}},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [campaigns, total] = await Promise.all([
|
||||
@@ -511,45 +521,71 @@ export class CampaignService {
|
||||
// segment after totalRecipients was calculated are silently skipped. Without this
|
||||
// reconciliation, sentCount can never reach the original totalRecipients and the
|
||||
// campaign remains stuck in SENDING forever.
|
||||
const [actualEmailCount, alreadySentCount] = await Promise.all([
|
||||
prisma.email.count({where: {campaignId}}),
|
||||
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
|
||||
]);
|
||||
const actualEmailCount = await prisma.email.count({where: {campaignId}});
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: actualEmailCount},
|
||||
});
|
||||
|
||||
// If all emails were already processed by the time the last batch finished
|
||||
// (race: worker was faster than the batch chain), finalize the campaign now.
|
||||
if (actualEmailCount === 0 || alreadySentCount >= actualEmailCount) {
|
||||
const finalCampaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {project: {select: {name: true}}},
|
||||
});
|
||||
|
||||
if (finalCampaign && finalCampaign.status === CampaignStatus.SENDING) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.SENT, sentCount: alreadySentCount},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[CAMPAIGN] Campaign ${finalCampaign.name} finalized after last batch: ${alreadySentCount}/${actualEmailCount} emails sent`,
|
||||
);
|
||||
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
finalCampaign.name,
|
||||
finalCampaign.project.name,
|
||||
finalCampaign.projectId,
|
||||
alreadySentCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.finalizeIfDone(campaignId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a SENDING campaign if every email has reached a terminal state.
|
||||
* Terminal = sentAt is set OR status is FAILED. Counting FAILED as terminal
|
||||
* unsticks campaigns where some emails couldn't be delivered (e.g. the project
|
||||
* was disabled mid-send), so the campaign moves to SENT with a partial sentCount.
|
||||
*/
|
||||
public static async finalizeIfDone(campaignId: string): Promise<void> {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {select: {name: true}},
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign || campaign.status !== CampaignStatus.SENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [processedCount, sentCount] = await Promise.all([
|
||||
prisma.email.count({
|
||||
where: {
|
||||
campaignId,
|
||||
OR: [{sentAt: {not: null}}, {status: EmailStatus.FAILED}],
|
||||
},
|
||||
}),
|
||||
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
|
||||
]);
|
||||
|
||||
if (campaign.totalRecipients > 0 && processedCount < campaign.totalRecipients) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.SENT, sentCount},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[CAMPAIGN] Campaign ${campaign.name} finalized: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
sentCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a campaign
|
||||
*/
|
||||
@@ -780,7 +816,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
// Use the SegmentService to build the where clause from the condition
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
const segmentWhere = await SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
@@ -823,7 +859,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
const segmentWhere = await SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
|
||||
@@ -135,6 +135,47 @@ export class ContactService {
|
||||
* Update a contact
|
||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||
*/
|
||||
/**
|
||||
* Merge an incoming partial data object into existing contact data.
|
||||
* - `null` value on a key deletes that key
|
||||
* - empty strings are ignored
|
||||
* - reserved/system-generated keys are silently filtered
|
||||
* - `{value, persistent: false}` entries are skipped (non-persistent)
|
||||
*/
|
||||
private static mergeContactData(
|
||||
existing: Prisma.JsonValue | null,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> =
|
||||
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
|
||||
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (reservedFields.includes(key)) continue;
|
||||
if (value === '') continue;
|
||||
if (value === null) {
|
||||
delete merged[key];
|
||||
continue;
|
||||
}
|
||||
if (key === 'locale' && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
(value as {persistent: unknown}).persistent === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
public static async update(
|
||||
projectId: string,
|
||||
contactId: string,
|
||||
@@ -149,7 +190,14 @@ export class ContactService {
|
||||
updateData.email = data.email;
|
||||
}
|
||||
if (data.data !== undefined) {
|
||||
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
|
||||
if (data.data === null) {
|
||||
updateData.data = Prisma.JsonNull;
|
||||
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
|
||||
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
|
||||
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
|
||||
} else {
|
||||
throw new HttpException(400, 'data must be an object');
|
||||
}
|
||||
}
|
||||
if (data.subscribed !== undefined) {
|
||||
updateData.subscribed = data.subscribed;
|
||||
@@ -225,69 +273,7 @@ export class ContactService {
|
||||
},
|
||||
});
|
||||
|
||||
// Process data to merge with existing data
|
||||
let mergedData: Record<string, unknown> = {};
|
||||
|
||||
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
|
||||
// Start with existing data
|
||||
mergedData = {...existing.data};
|
||||
}
|
||||
|
||||
// Merge new data (if provided)
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty string values - they don't provide meaningful data
|
||||
// and can cause issues with template rendering and data integrity
|
||||
if (value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete field if null is passed (allows removing fields from contact data)
|
||||
if (value === null) {
|
||||
delete mergedData[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
value.persistent === false
|
||||
) {
|
||||
// Non-persistent fields are not stored in contact data
|
||||
// They would be used only for the current operation (like email template rendering)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the value
|
||||
mergedData[key] = value;
|
||||
}
|
||||
}
|
||||
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
|
||||
|
||||
if (existing) {
|
||||
// Track subscription status change
|
||||
@@ -721,99 +707,81 @@ export class ContactService {
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
* Updates multiple contacts to subscribed=true in batches.
|
||||
* `updated` = contacts flipped from unsubscribed to subscribed.
|
||||
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
|
||||
*/
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
public static async bulkSubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number; unchanged: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
where: {id: {in: contactIds}, projectId},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
if (contacts.length === 0) {
|
||||
return {updated: 0, unchanged: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently unsubscribed
|
||||
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||
const unchanged = contacts.length - unsubscribedIds.length;
|
||||
|
||||
if (unsubscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
return {updated: 0, unchanged};
|
||||
}
|
||||
|
||||
// Update in a single query for performance
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: unsubscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: true,
|
||||
},
|
||||
where: {id: {in: unsubscribedIds}, projectId},
|
||||
data: {subscribed: true},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
return {updated: result.count, unchanged};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
* Bulk unsubscribe contacts.
|
||||
* `updated` = contacts flipped from subscribed to unsubscribed.
|
||||
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
|
||||
*/
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
public static async bulkUnsubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number; unchanged: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
where: {id: {in: contactIds}, projectId},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
if (contacts.length === 0) {
|
||||
return {updated: 0, unchanged: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently subscribed
|
||||
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||
const unchanged = contacts.length - subscribedIds.length;
|
||||
|
||||
if (subscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
return {updated: 0, unchanged};
|
||||
}
|
||||
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: subscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: false,
|
||||
},
|
||||
where: {id: {in: subscribedIds}, projectId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
return {updated: result.count, unchanged};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -438,15 +438,14 @@ 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: {
|
||||
include: {
|
||||
members: {
|
||||
where: {userId},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -456,8 +455,20 @@ export class DomainService {
|
||||
return {exists: false};
|
||||
}
|
||||
|
||||
// Check if user is a member of the project that owns this domain
|
||||
const isMember = existingDomain.project.members.length > 0;
|
||||
let isMember = false;
|
||||
|
||||
if (userId) {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: existingDomain.project.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
isMember = membership !== null;
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
|
||||
@@ -108,7 +108,7 @@ export class EmailService {
|
||||
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
|
||||
|
||||
// Queue email for sending
|
||||
await this.queueEmail(email.id);
|
||||
await this.queueEmail(email.id, EmailSourceType.TRANSACTIONAL);
|
||||
|
||||
return email;
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export class EmailService {
|
||||
await BillingLimitService.incrementUsage(params.projectId, sourceType);
|
||||
|
||||
// Queue email for sending
|
||||
await this.queueEmail(email.id);
|
||||
await this.queueEmail(email.id, sourceType);
|
||||
|
||||
return email;
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export class EmailService {
|
||||
await BillingLimitService.incrementUsage(params.projectId, sourceType);
|
||||
|
||||
// Queue email for sending
|
||||
await this.queueEmail(email.id);
|
||||
await this.queueEmail(email.id, sourceType);
|
||||
|
||||
return email;
|
||||
}
|
||||
@@ -659,12 +659,16 @@ 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) {
|
||||
@@ -679,21 +683,21 @@ export class EmailService {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// 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 hasMediaQueries = /@media/i.test(html);
|
||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||
|
||||
return (
|
||||
hasInlineStyles ||
|
||||
hasCustomClasses ||
|
||||
hasCustomAttributes ||
|
||||
hasComplexTables ||
|
||||
hasCustomElements ||
|
||||
hasMediaQueries ||
|
||||
hasStyleTags
|
||||
);
|
||||
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1133,7 +1137,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, delay?: number): Promise<void> {
|
||||
await QueueService.queueEmail(emailId, delay);
|
||||
private static async queueEmail(emailId: string, sourceType: EmailSourceType, delay?: number): Promise<void> {
|
||||
await QueueService.queueEmail(emailId, sourceType, delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +799,28 @@ export class NtfyService {
|
||||
});
|
||||
}
|
||||
|
||||
public static async notifyEarlyFraudWarning(
|
||||
chargeId: string,
|
||||
fraudType: string,
|
||||
cardFingerprint: string | null,
|
||||
customerEmail: string | null,
|
||||
): Promise<void> {
|
||||
const details = [
|
||||
`Charge: ${chargeId}`,
|
||||
`Fraud type: ${fraudType}`,
|
||||
cardFingerprint ? `Card fingerprint: ${cardFingerprint}` : null,
|
||||
customerEmail ? `Customer: ${customerEmail}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
await this.sendUrgent('Stripe Early Fraud Warning', `Refunded and blocklisted. ${details}`, [
|
||||
NtfyTag.SHIELD,
|
||||
NtfyTag.MONEY,
|
||||
NtfyTag.SKULL,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ntfy service with the configured URL
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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,
|
||||
@@ -172,20 +174,43 @@ 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
|
||||
* 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.
|
||||
*/
|
||||
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
|
||||
public static async queueEmail(
|
||||
emailId: string,
|
||||
sourceType: EmailSourceType,
|
||||
delay?: number,
|
||||
): Promise<Job<SendEmailJobData>> {
|
||||
return emailQueue.add(
|
||||
'send-email',
|
||||
{emailId},
|
||||
{
|
||||
delay, // Optional delay in milliseconds
|
||||
jobId: `email-${emailId}`, // Prevent duplicate jobs
|
||||
delay,
|
||||
jobId: `email-${emailId}`,
|
||||
priority: emailPriorityFor(sourceType),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -349,12 +374,12 @@ export class QueueService {
|
||||
*/
|
||||
public static async queueBulkContactAction(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
selector: BulkContactActionSelector,
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
): Promise<Job<BulkContactActionJobData>> {
|
||||
return bulkContactQueue.add(
|
||||
'bulk-contact-action',
|
||||
{projectId, contactIds, operation},
|
||||
{projectId, operation, selector},
|
||||
{
|
||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||
},
|
||||
@@ -578,6 +603,39 @@ export class QueueService {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark every still-PENDING email for this project as FAILED. We just stripped
|
||||
// their queue jobs, so without this they'd sit as PENDING forever and any
|
||||
// campaign waiting on them would stay stuck in SENDING.
|
||||
const failed = await prisma.email.updateMany({
|
||||
where: {projectId, status: EmailStatus.PENDING},
|
||||
data: {status: EmailStatus.FAILED, error: 'Project is disabled'},
|
||||
});
|
||||
|
||||
if (failed.count > 0) {
|
||||
signale.info(`[QUEUE] Marked ${failed.count} pending emails as failed for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Finalize any in-flight campaigns. With the orphaned PENDING emails now FAILED
|
||||
// (terminal), the campaign can move to SENT with a partial sentCount instead of
|
||||
// staying stuck in SENDING. Reconcile totalRecipients first since the batch
|
||||
// chain may have been cut short.
|
||||
const sendingCampaigns = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.SENDING},
|
||||
select: {id: true},
|
||||
});
|
||||
|
||||
if (sendingCampaigns.length > 0) {
|
||||
const {CampaignService} = await import('./CampaignService.js');
|
||||
for (const campaign of sendingCampaigns) {
|
||||
const actualEmailCount = await prisma.email.count({where: {campaignId: campaign.id}});
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {totalRecipients: actualEmailCount},
|
||||
});
|
||||
await CampaignService.finalizeIfDone(campaign.id);
|
||||
}
|
||||
}
|
||||
|
||||
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AWS_SES_REGION,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
@@ -250,10 +251,13 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
|
||||
// Verify DKIM for the domain
|
||||
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
||||
|
||||
// Set custom MAIL FROM domain (plunk.yourdomain.com)
|
||||
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be
|
||||
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
|
||||
// is already in use for something else (e.g., a CNAME to a CDN), since the
|
||||
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: domain,
|
||||
MailFromDomain: `plunk.${domain}`,
|
||||
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens ?? [];
|
||||
|
||||
@@ -50,22 +50,13 @@ const SECURITY_THRESHOLDS = {
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
|
||||
// === 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
|
||||
// === 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.
|
||||
NEW_PROJECT_AGE_DAYS: 30,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
|
||||
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
|
||||
@@ -516,82 +507,54 @@ export class SecurityService {
|
||||
const violations: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// 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,
|
||||
};
|
||||
// === 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})`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// 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 projectLabel = isNewProject ? ' (new project)' : '';
|
||||
// 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})`,
|
||||
);
|
||||
}
|
||||
|
||||
// === 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})`,
|
||||
);
|
||||
// 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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === Rate-based checks (existing logic) ===
|
||||
@@ -713,7 +676,7 @@ export class SecurityService {
|
||||
// Disable the project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
|
||||
});
|
||||
|
||||
// Log critical security event
|
||||
@@ -802,7 +765,36 @@ export class SecurityService {
|
||||
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
|
||||
|
||||
// Extract sender domain for context
|
||||
const senderDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : fromEmail;
|
||||
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;
|
||||
}
|
||||
|
||||
// Call OpenRouter API
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
@@ -835,7 +827,11 @@ 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. Links to the sender's own domain(s) are expected and NOT suspicious.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -848,6 +844,8 @@ 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}
|
||||
@@ -971,7 +969,7 @@ ${strippedBody.substring(0, 2000)}`,
|
||||
// Disable the project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
|
||||
});
|
||||
|
||||
const violation = `A policy violation was detected. Please contact support for more details.`;
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
@@ -137,7 +137,7 @@ export class SegmentService {
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const where = await this.buildWhereClause(projectId, data.condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
conditionJson = toPrismaJson(data.condition);
|
||||
}
|
||||
@@ -192,10 +192,14 @@ export class SegmentService {
|
||||
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||
// Validate condition if provided (only for DYNAMIC segments)
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
if (this.getReferencedSegmentIds(data.condition).has(segmentId)) {
|
||||
throw new HttpException(400, 'A segment cannot reference itself');
|
||||
}
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const where = await this.buildWhereClause(projectId, data.condition);
|
||||
updateData.memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
if (data.trackMembership !== undefined) {
|
||||
@@ -267,7 +271,7 @@ export class SegmentService {
|
||||
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
@@ -305,7 +309,7 @@ export class SegmentService {
|
||||
});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
@@ -460,7 +464,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
|
||||
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
||||
const BATCH_SIZE = 1000;
|
||||
@@ -629,9 +633,57 @@ export class SegmentService {
|
||||
/**
|
||||
* Build a single filter condition
|
||||
*/
|
||||
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
|
||||
public static async buildFilterCondition(
|
||||
filter: SegmentFilter,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const {field, operator, value, unit} = filter;
|
||||
|
||||
// Handle segment membership filters (e.g., "segment.<segmentId>")
|
||||
if (field.startsWith('segment.')) {
|
||||
const segmentId = field.substring(8);
|
||||
|
||||
if (visitedSegments.has(segmentId)) {
|
||||
throw new HttpException(400, 'Circular segment reference detected');
|
||||
}
|
||||
|
||||
const referencedSegment = await prisma.segment.findUnique({
|
||||
where: {id: segmentId},
|
||||
});
|
||||
|
||||
if (!referencedSegment) {
|
||||
throw new HttpException(400, `Referenced segment not found: ${segmentId}`);
|
||||
}
|
||||
|
||||
let memberIds: string[];
|
||||
|
||||
if (referencedSegment.type === 'STATIC' || referencedSegment.trackMembership) {
|
||||
// Use the membership table for static or tracked segments
|
||||
const memberships = await prisma.segmentMembership.findMany({
|
||||
where: {segmentId, exitedAt: null},
|
||||
select: {contactId: true},
|
||||
});
|
||||
memberIds = memberships.map(m => m.contactId);
|
||||
} else {
|
||||
// For untracked dynamic segments, evaluate the condition recursively
|
||||
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
|
||||
const nextVisited = new Set(visitedSegments).add(segmentId);
|
||||
const nestedWhere = await this.buildConditionClause(nestedCondition, nextVisited);
|
||||
|
||||
if (operator === 'memberOfSegment') {
|
||||
return nestedWhere;
|
||||
}
|
||||
|
||||
return {NOT: nestedWhere};
|
||||
}
|
||||
|
||||
if (operator === 'memberOfSegment') {
|
||||
return {id: {in: memberIds}};
|
||||
}
|
||||
|
||||
return {id: {notIn: memberIds}};
|
||||
}
|
||||
|
||||
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
|
||||
if (field.startsWith('event.')) {
|
||||
const eventName = field.substring(6); // Remove "event." prefix
|
||||
@@ -664,6 +716,26 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all segment IDs directly referenced in a condition (non-recursive DB lookup)
|
||||
*/
|
||||
private static getReferencedSegmentIds(condition: FilterCondition): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of condition.groups) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field.startsWith('segment.')) {
|
||||
ids.add(filter.field.substring(8));
|
||||
}
|
||||
}
|
||||
if (group.conditions) {
|
||||
for (const id of this.getReferencedSegmentIds(group.conditions)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate segment condition (recursive)
|
||||
*/
|
||||
@@ -688,8 +760,13 @@ export class SegmentService {
|
||||
/**
|
||||
* Build Prisma clause from filter condition (recursive)
|
||||
*/
|
||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||
public static async buildConditionClause(
|
||||
condition: FilterCondition,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const groupClauses = await Promise.all(
|
||||
condition.groups.map(group => this.buildGroupClause(group, visitedSegments)),
|
||||
);
|
||||
|
||||
if (condition.logic === 'AND') {
|
||||
return {AND: groupClauses};
|
||||
@@ -758,12 +835,23 @@ export class SegmentService {
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggered',
|
||||
'notTriggeredWithin',
|
||||
'memberOfSegment',
|
||||
'notMemberOfSegment',
|
||||
];
|
||||
|
||||
if (!validOperators.includes(filter.operator)) {
|
||||
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
|
||||
}
|
||||
|
||||
// Segment membership operators use the segmentId encoded in the field name, no separate value needed
|
||||
if (filter.operator === 'memberOfSegment' || filter.operator === 'notMemberOfSegment') {
|
||||
if (!filter.field.startsWith('segment.')) {
|
||||
throw new HttpException(400, 'memberOfSegment/notMemberOfSegment operators require a segment field (segment.<id>)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that operators that need a value have one
|
||||
const operatorsNeedingValue = [
|
||||
'equals',
|
||||
@@ -778,6 +866,7 @@ export class SegmentService {
|
||||
'olderThan',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggeredWithin',
|
||||
];
|
||||
|
||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||
@@ -785,7 +874,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
// Validate unit for time-based operators
|
||||
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator) && !filter.unit) {
|
||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||
}
|
||||
}
|
||||
@@ -793,27 +882,36 @@ export class SegmentService {
|
||||
/**
|
||||
* Build Prisma where clause from filter condition (entry point)
|
||||
*/
|
||||
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
private static async buildWhereClause(
|
||||
projectId: string,
|
||||
condition: FilterCondition,
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
return {
|
||||
projectId,
|
||||
...this.buildConditionClause(condition),
|
||||
...(await this.buildConditionClause(condition)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter group (recursive)
|
||||
*/
|
||||
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
|
||||
private static async buildGroupClause(
|
||||
group: FilterGroup,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const clauses: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
// Add filter conditions from this group
|
||||
if (group.filters.length > 0) {
|
||||
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
|
||||
const filterClauses = await Promise.all(
|
||||
group.filters.map(filter => this.buildFilterCondition(filter, visitedSegments)),
|
||||
);
|
||||
clauses.push(...filterClauses);
|
||||
}
|
||||
|
||||
// Add nested condition if present
|
||||
if (group.conditions) {
|
||||
clauses.push(this.buildConditionClause(group.conditions));
|
||||
clauses.push(await this.buildConditionClause(group.conditions, visitedSegments));
|
||||
}
|
||||
|
||||
// All conditions within a group are combined with AND
|
||||
@@ -1166,6 +1264,28 @@ export class SegmentService {
|
||||
},
|
||||
};
|
||||
|
||||
case 'notTriggeredWithin': {
|
||||
// Contact has not triggered this event within the timeframe (includes never-triggered contacts)
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const since = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
events: {
|
||||
none: {
|
||||
name: eventName,
|
||||
createdAt: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
|
||||
}
|
||||
@@ -1181,13 +1301,18 @@ export class SegmentService {
|
||||
value: unknown,
|
||||
unit?: 'days' | 'hours' | 'minutes',
|
||||
): Prisma.ContactWhereInput {
|
||||
// Map activity names to Email model fields
|
||||
// Map activity names to Email model fields (accept both verb and past-tense forms)
|
||||
const fieldMap: Record<string, string> = {
|
||||
open: 'openedAt',
|
||||
opened: 'openedAt',
|
||||
click: 'clickedAt',
|
||||
clicked: 'clickedAt',
|
||||
bounce: 'bouncedAt',
|
||||
bounced: 'bouncedAt',
|
||||
complaint: 'complainedAt',
|
||||
complained: 'complainedAt',
|
||||
sent: 'sentAt',
|
||||
delivery: 'deliveredAt',
|
||||
delivered: 'deliveredAt',
|
||||
};
|
||||
|
||||
@@ -1278,6 +1403,27 @@ export class SegmentService {
|
||||
},
|
||||
};
|
||||
|
||||
case 'notTriggeredWithin': {
|
||||
// Contact has not had this email activity within the timeframe (includes contacts with no activity)
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const since = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
emails: {
|
||||
none: {
|
||||
[field]: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
|
||||
}
|
||||
|
||||
@@ -908,7 +908,14 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* WEBHOOK step - Call an external webhook
|
||||
* 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.
|
||||
*/
|
||||
private static async executeWebhook(
|
||||
_step: WorkflowStep,
|
||||
@@ -924,9 +931,37 @@ 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 || {};
|
||||
|
||||
const payload = body || {
|
||||
// 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 || {
|
||||
contact: {
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
@@ -944,11 +979,11 @@ export class WorkflowExecutionService {
|
||||
};
|
||||
|
||||
// Make HTTP request
|
||||
const response = await WorkflowExecutionService.safeFetch(url, {
|
||||
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
...renderedHeaders,
|
||||
},
|
||||
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
@@ -962,7 +997,7 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
url: renderedUrl,
|
||||
method,
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
@@ -970,6 +1005,28 @@ 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
|
||||
*/
|
||||
@@ -979,7 +1036,7 @@ export class WorkflowExecutionService {
|
||||
_stepExecution: WorkflowStepExecution,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||
|
||||
const contact = execution.contact;
|
||||
const currentData =
|
||||
@@ -987,24 +1044,43 @@ export class WorkflowExecutionService {
|
||||
? (contact.data as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Merge updates with current data
|
||||
const newData = {
|
||||
...currentData,
|
||||
...updates,
|
||||
};
|
||||
const hasDataUpdates = updates && Object.keys(updates).length > 0;
|
||||
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
|
||||
|
||||
// Update contact in database
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: {
|
||||
data: newData ? toPrismaJson(newData) : undefined,
|
||||
},
|
||||
});
|
||||
const desiredSubscribed =
|
||||
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
|
||||
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
|
||||
|
||||
const updateData: Prisma.ContactUpdateInput = {};
|
||||
if (hasDataUpdates) {
|
||||
updateData.data = toPrismaJson(newData);
|
||||
}
|
||||
if (subscriptionChanging) {
|
||||
updateData.subscribed = desiredSubscribed;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
if (subscriptionChanging) {
|
||||
const {EventService} = await import('./EventService.js');
|
||||
await EventService.trackEvent(
|
||||
execution.workflow.projectId,
|
||||
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
|
||||
contact.id,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
updated: true,
|
||||
updated: hasDataUpdates || subscriptionChanging,
|
||||
updates,
|
||||
newData,
|
||||
subscriptionAction,
|
||||
subscribed: desiredSubscribed ?? contact.subscribed,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,72 @@ export class WorkflowService {
|
||||
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow including all steps and transitions.
|
||||
* The duplicate always starts disabled to prevent accidental triggering.
|
||||
* Runtime execution state is intentionally not copied.
|
||||
*/
|
||||
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
|
||||
const source = await this.get(projectId, workflowId);
|
||||
|
||||
const transitions = await prisma.workflowTransition.findMany({
|
||||
where: {fromStep: {workflowId}},
|
||||
});
|
||||
|
||||
return prisma.$transaction(async tx => {
|
||||
const newWorkflow = await tx.workflow.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${source.name} (Copy)`,
|
||||
description: source.description,
|
||||
triggerType: source.triggerType,
|
||||
triggerConfig:
|
||||
source.triggerConfig === null
|
||||
? Prisma.JsonNull
|
||||
: (source.triggerConfig as Prisma.InputJsonValue),
|
||||
enabled: false,
|
||||
allowReentry: source.allowReentry,
|
||||
},
|
||||
});
|
||||
|
||||
const stepIdMap = new Map<string, string>();
|
||||
|
||||
for (const step of source.steps) {
|
||||
const created = await tx.workflowStep.create({
|
||||
data: {
|
||||
workflowId: newWorkflow.id,
|
||||
type: step.type,
|
||||
name: step.name,
|
||||
position: step.position as Prisma.InputJsonValue,
|
||||
config: step.config as Prisma.InputJsonValue,
|
||||
templateId: step.templateId,
|
||||
},
|
||||
});
|
||||
stepIdMap.set(step.id, created.id);
|
||||
}
|
||||
|
||||
for (const transition of transitions) {
|
||||
const fromStepId = stepIdMap.get(transition.fromStepId);
|
||||
const toStepId = stepIdMap.get(transition.toStepId);
|
||||
if (!fromStepId || !toStepId) continue;
|
||||
|
||||
await tx.workflowTransition.create({
|
||||
data: {
|
||||
fromStepId,
|
||||
toStepId,
|
||||
condition:
|
||||
transition.condition === null
|
||||
? Prisma.JsonNull
|
||||
: (transition.condition as Prisma.InputJsonValue),
|
||||
priority: transition.priority,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return newWorkflow;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a step to a workflow
|
||||
*/
|
||||
|
||||
@@ -50,27 +50,20 @@ describe('SecurityService', () => {
|
||||
const complainedCount = opts?.complainedCount ?? 0;
|
||||
const createdAt = opts?.createdAt ?? new Date();
|
||||
|
||||
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);
|
||||
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});
|
||||
}
|
||||
|
||||
describe('Rate-based checks (existing behavior)', () => {
|
||||
@@ -109,14 +102,12 @@ 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('Absolute count ceilings (established projects)', () => {
|
||||
// Age the project past the new-project window so standard ceilings apply
|
||||
describe('Established projects skip absolute ceilings', () => {
|
||||
// Age the project past the new-project window
|
||||
beforeEach(async () => {
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
@@ -125,45 +116,27 @@ describe('SecurityService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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});
|
||||
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});
|
||||
|
||||
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', () => {
|
||||
@@ -178,7 +151,7 @@ describe('SecurityService', () => {
|
||||
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should apply standard ceilings for projects over 30 days old', async () => {
|
||||
it('should NOT apply absolute 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({
|
||||
@@ -186,15 +159,14 @@ describe('SecurityService', () => {
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
|
||||
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
|
||||
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
|
||||
// established projects skip ceilings entirely (rate is 0.26%, healthy).
|
||||
await createEmails(10000, {bouncedCount: 26});
|
||||
|
||||
const status = await SecurityService.getSecurityStatus(projectId);
|
||||
expect(status.isNewProject).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);
|
||||
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
|
||||
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should catch new project blasting emails with delayed bounces', async () => {
|
||||
@@ -212,8 +184,8 @@ describe('SecurityService', () => {
|
||||
|
||||
describe('checkAndEnforceSecurityLimits', () => {
|
||||
it('should disable project when critical thresholds are exceeded', async () => {
|
||||
// Create enough bounces to trigger critical
|
||||
await createEmails(20000, {bouncedCount: 101});
|
||||
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
|
||||
await createEmails(20000, {bouncedCount: 30});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
@@ -225,13 +197,13 @@ describe('SecurityService', () => {
|
||||
});
|
||||
|
||||
it('should NOT disable project when only warnings exist', async () => {
|
||||
// 10,000 emails, 51 bounces (above warning but below critical for established project)
|
||||
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
|
||||
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {createdAt: oldDate},
|
||||
});
|
||||
await createEmails(10000, {bouncedCount: 51});
|
||||
await createEmails(200, {bouncedCount: 12});
|
||||
|
||||
await SecurityService.checkAndEnforceSecurityLimits(projectId);
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,9 @@ export const Keys = {
|
||||
stats(projectId: string, startTime: number | string, endTime: number | string): string {
|
||||
return `activity:stats:${projectId}:${startTime}:${endTime}`;
|
||||
},
|
||||
recentCount(projectId: string, minutes: number): string {
|
||||
return `activity:recent-count:${projectId}:${minutes}`;
|
||||
},
|
||||
},
|
||||
Analytics: {
|
||||
timeseries(projectId: string, startDate: string, endDate: string): string {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
# Plunk
|
||||
|
||||
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
|
||||
|
||||
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
|
||||
|
||||
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
|
||||
|
||||
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
|
||||
|
||||
## Product
|
||||
|
||||
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
|
||||
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
|
||||
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
|
||||
|
||||
## Features
|
||||
|
||||
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
|
||||
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
|
||||
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
|
||||
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
|
||||
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
|
||||
|
||||
## Comparisons
|
||||
|
||||
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
|
||||
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
|
||||
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
|
||||
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
|
||||
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
|
||||
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
|
||||
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
|
||||
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
|
||||
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
|
||||
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
|
||||
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
|
||||
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
|
||||
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
|
||||
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
|
||||
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
|
||||
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
|
||||
- [All comparisons](https://www.useplunk.com/vs)
|
||||
|
||||
## Guides
|
||||
|
||||
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
|
||||
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
|
||||
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
|
||||
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
|
||||
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
|
||||
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
|
||||
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
|
||||
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
|
||||
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
|
||||
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
|
||||
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
|
||||
- [All guides](https://www.useplunk.com/guides)
|
||||
|
||||
## Tools
|
||||
|
||||
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
|
||||
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
|
||||
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
|
||||
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
|
||||
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
|
||||
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
|
||||
- [MX checker](https://www.useplunk.com/tools/mx-checker)
|
||||
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
|
||||
- [All tools](https://www.useplunk.com/tools)
|
||||
|
||||
## Optional
|
||||
|
||||
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
|
||||
- [Discord community](https://www.useplunk.com/discord)
|
||||
- [Privacy policy](https://www.useplunk.com/privacy)
|
||||
- [Terms of service](https://www.useplunk.com/terms)
|
||||
- [Data processing agreement](https://www.useplunk.com/dpa)
|
||||
@@ -1,5 +1,6 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {WIKI_URI} from '../../lib/constants';
|
||||
import logo from '../../../public/assets/logo.svg';
|
||||
|
||||
@@ -7,6 +8,10 @@ import logo from '../../../public/assets/logo.svg';
|
||||
*
|
||||
*/
|
||||
export default function Footer() {
|
||||
const router = useRouter();
|
||||
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
|
||||
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const mdHref = `${trimmed}.md`;
|
||||
return (
|
||||
<>
|
||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||
@@ -87,6 +92,25 @@ export default function Footer() {
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Checkers</h3>
|
||||
<ul role="list" className="mt-6 space-y-4">
|
||||
<li>
|
||||
<Link href={'/tools/spf-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
SPF checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dmarc-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DMARC checker
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/tools/dkim-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DKIM checker
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -244,8 +268,15 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
||||
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
|
||||
Reading this with electronic eyes? Append{' '}
|
||||
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
|
||||
<code>.md</code>
|
||||
</a>{' '}
|
||||
to any URL for the Markdown cut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {ArrowRight, Gift, X} from 'lucide-react';
|
||||
import {DASHBOARD_URI} from '../lib/constants';
|
||||
|
||||
interface SwitchOfferProps {
|
||||
competitorName: string;
|
||||
}
|
||||
|
||||
export function SwitchOffer({competitorName}: SwitchOfferProps) {
|
||||
const storageKey = `switch-offer-dismissed:${competitorName}`;
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.localStorage.getItem(storageKey) === '1') return;
|
||||
const t = window.setTimeout(() => setVisible(true), 800);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [storageKey]);
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(storageKey, '1');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 24, scale: 0.96}}
|
||||
animate={{opacity: 1, y: 0, scale: 1}}
|
||||
exit={{opacity: 0, y: 16, scale: 0.97}}
|
||||
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
|
||||
}
|
||||
role="dialog"
|
||||
aria-label={`Switch from ${competitorName} to Plunk offer`}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss offer"
|
||||
className={
|
||||
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
|
||||
<div
|
||||
className={
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
|
||||
}
|
||||
>
|
||||
<Gift className="h-3 w-3 text-neutral-700" />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
|
||||
>
|
||||
Switching offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
Switching from {competitorName}?
|
||||
<br />
|
||||
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
|
||||
</h3>
|
||||
|
||||
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
|
||||
Sign up and enter this code at checkout to redeem 2,000 email credits.
|
||||
</p>
|
||||
|
||||
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
|
||||
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
|
||||
>
|
||||
SWITCH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Claim your credits
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {Footer, Navbar} from '../';
|
||||
import {FAQSection, Footer, Navbar} from '../';
|
||||
import type {FAQ} from '../FAQSection';
|
||||
import {motion} from 'framer-motion';
|
||||
import React, {ReactNode, useLayoutEffect, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
@@ -13,6 +14,7 @@ interface GuideLayoutProps {
|
||||
children: ReactNode;
|
||||
canonical?: string;
|
||||
ogImage?: string;
|
||||
faqs?: FAQ[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,8 +27,12 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
ogImage,
|
||||
faqs,
|
||||
}: GuideLayoutProps) {
|
||||
const resolvedOgImage =
|
||||
ogImage ||
|
||||
`https://www.useplunk.com/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&tag=Guide`;
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
|
||||
@@ -90,7 +96,7 @@ export function GuideLayout({
|
||||
description: description,
|
||||
url: canonical,
|
||||
type: 'article',
|
||||
images: [{url: ogImage, alt: title}],
|
||||
images: [{url: resolvedOgImage, alt: title, width: 1200, height: 630}],
|
||||
article: {
|
||||
publishedTime: lastUpdated,
|
||||
modifiedTime: lastUpdated,
|
||||
@@ -103,7 +109,7 @@ export function GuideLayout({
|
||||
type="Article"
|
||||
url={canonical || ''}
|
||||
title={title}
|
||||
images={[ogImage]}
|
||||
images={[resolvedOgImage]}
|
||||
datePublished={lastUpdated}
|
||||
dateModified={lastUpdated}
|
||||
authorName="Plunk"
|
||||
@@ -241,6 +247,13 @@ export function GuideLayout({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{faqs && faqs.length > 0 && (
|
||||
<FAQSection
|
||||
faqs={faqs}
|
||||
schemaId={`faq-${canonical ? canonical.split('/').pop() : 'guide'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './ComparisonTable';
|
||||
export * from './FAQSection';
|
||||
export * from './CodeBlock';
|
||||
export * from './SectionHeader';
|
||||
export * from './SwitchOffer';
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
|
||||
'index',
|
||||
'pricing',
|
||||
'features/workflows',
|
||||
'features/segments',
|
||||
'features/inbound-email',
|
||||
'features/email-editor',
|
||||
'features/smtp',
|
||||
]);
|
||||
|
||||
export function hasMarkdownVariant(pathname: string): boolean {
|
||||
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||
return MARKDOWN_SLUGS.has(slug);
|
||||
}
|
||||
@@ -341,3 +341,5 @@ Plunk provides SMTP credentials so you can send emails from any application or f
|
||||
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||
`,
|
||||
};
|
||||
|
||||
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { hasMarkdownVariant } from './content/markdown-slugs';
|
||||
|
||||
type Negotiated = 'markdown' | 'html' | 'none';
|
||||
|
||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||
@@ -26,7 +28,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 = getQ(types, 'text/markdown');
|
||||
const mdQ = types.find(t => t.type === 'text/markdown')?.q ?? -1;
|
||||
const htmlQ = getQ(types, 'text/html');
|
||||
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||
@@ -59,9 +61,12 @@ 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;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt)).*)',],
|
||||
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
|
||||
};
|
||||
|
||||
@@ -2,9 +2,11 @@ import '../styles/globals.css';
|
||||
import React, {useEffect} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {AppProps} from 'next/app';
|
||||
import {useRouter} from 'next/router';
|
||||
import {toast, Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {network} from '../lib/network';
|
||||
import {hasMarkdownVariant} from '../content/markdown-slugs';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import Script from 'next/script';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
@@ -37,6 +39,10 @@ const mono = JetBrains_Mono({
|
||||
* @param props.pageProps
|
||||
*/
|
||||
function App({Component, pageProps}: AppProps) {
|
||||
const router = useRouter();
|
||||
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
|
||||
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const message = searchParams.get('message');
|
||||
@@ -51,6 +57,7 @@ function App({Component, pageProps}: AppProps) {
|
||||
<Head>
|
||||
<title>Plunk | The Open-Source Email Platform</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
||||
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
|
||||
</Head>
|
||||
<Toaster position={'top-right'} />
|
||||
|
||||
@@ -82,7 +89,14 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [
|
||||
{
|
||||
url: `https://www.useplunk.com/api/og?title=${encodeURIComponent('The Open-Source Email Platform')}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk',
|
||||
},
|
||||
],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -7,12 +7,13 @@ export default class MyDocument extends Document {
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
{/* Start favicon */}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {ImageResponse} from 'next/og';
|
||||
import type {NextRequest} from 'next/server';
|
||||
|
||||
import {getBricolageFont} from '../../lib/og-font';
|
||||
|
||||
export const config = {runtime: 'edge'};
|
||||
|
||||
const PLUNK_P_PATH =
|
||||
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
|
||||
|
||||
export default function handler(req: NextRequest) {
|
||||
const {searchParams} = new URL(req.url);
|
||||
const title = searchParams.get('title') || 'The Open-Source Email Platform';
|
||||
const description = searchParams.get('description') || '';
|
||||
const tag = searchParams.get('tag') || '';
|
||||
|
||||
const fontData = getBricolageFont();
|
||||
|
||||
const titleLength = title.length;
|
||||
const fontSize = titleLength < 35 ? 144 : titleLength < 55 ? 120 : titleLength < 75 ? 100 : 84;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '2400px',
|
||||
height: '1260px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '128px 144px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: '"Bricolage Grotesque"',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Ghost P watermark */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-120px',
|
||||
right: '-60px',
|
||||
opacity: 0.035,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="960" height="960" viewBox="0 0 1080 1080" fill="none">
|
||||
<path d={PLUNK_P_PATH} fill="#000000" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Top row: logo + URL */}
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '20px'}}>
|
||||
<svg width="60" height="60" viewBox="0 0 1080 1080" fill="none">
|
||||
<path d={PLUNK_P_PATH} fill="#171717" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '44px',
|
||||
fontWeight: 800,
|
||||
color: '#171717',
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
>
|
||||
Plunk
|
||||
</span>
|
||||
</div>
|
||||
<span style={{fontSize: '30px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
|
||||
useplunk.com
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '36px', maxWidth: '1840px'}}>
|
||||
{tag ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 28px',
|
||||
backgroundColor: '#f4f4f5',
|
||||
borderRadius: '12px',
|
||||
fontSize: '30px',
|
||||
color: '#52525b',
|
||||
fontWeight: 600,
|
||||
width: 'fit-content',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: 800,
|
||||
color: '#0a0a0a',
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: '-0.04em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '44px',
|
||||
color: '#737373',
|
||||
lineHeight: 1.5,
|
||||
fontWeight: 400,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{display: 'flex'}} />
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 2400,
|
||||
height: 1260,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Bricolage Grotesque',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
weight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,9 @@ export default function EmailEditorFeature() {
|
||||
property="og:description"
|
||||
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -78,6 +78,9 @@ export default function InboundEmailFeature() {
|
||||
property="og:description"
|
||||
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -93,6 +93,9 @@ export default function SegmentsFeature() {
|
||||
property="og:description"
|
||||
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -89,6 +89,9 @@ export default function SMTPFeature() {
|
||||
property="og:description"
|
||||
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -78,6 +78,9 @@ export default function WorkflowsFeature() {
|
||||
property="og:description"
|
||||
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -6,8 +6,8 @@ import Link from 'next/link';
|
||||
export default function EmailAPIGuide() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="Email API Guide: Everything You Need to Know with Code Examples"
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
title="What is an Email API? Complete Guide with Code Examples | Plunk"
|
||||
description="An email API lets you send, receive, and manage emails programmatically. Learn how they work, see code examples in Node.js, Python, PHP, Ruby, and Go, and choose the right provider."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
|
||||
@@ -2,6 +2,35 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a good email bounce rate?',
|
||||
answer:
|
||||
'A bounce rate below 2% is considered excellent. Between 2–5% is concerning and needs attention. Above 5% is critical and will seriously damage your sender reputation and deliverability. For hard bounces specifically, keep this below 0.5% per campaign. Most reputable email platforms will suspend accounts or pause sending if hard bounce rates exceed 5–10%.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between a hard bounce and a soft bounce?',
|
||||
answer:
|
||||
'A hard bounce is a permanent delivery failure—the email address does not exist, the domain is invalid, or the receiving server has permanently blocked delivery. Remove hard bounce addresses immediately and never send to them again. A soft bounce is a temporary failure—the mailbox is full, the server is temporarily down, or the email is too large. Email platforms typically retry soft bounces automatically for 24–72 hours.',
|
||||
},
|
||||
{
|
||||
question: 'How do I reduce email bounce rates?',
|
||||
answer:
|
||||
'Key strategies to reduce bounces: (1) Use double opt-in to confirm email addresses at signup, (2) Validate email addresses in real-time on forms to catch typos and invalid formats, (3) Remove hard bounces immediately and automatically, (4) Clean your list every 6–12 months to remove stale addresses (15–20% of email addresses go invalid per year), (5) Never purchase email lists—they have 20–40% invalid addresses, and (6) Maintain consistent sending to prevent addresses going stale.',
|
||||
},
|
||||
{
|
||||
question: 'Do bounces affect my email deliverability?',
|
||||
answer:
|
||||
'Yes, high bounce rates directly damage your sender reputation. Email providers like Gmail and Outlook track bounce rates as a signal of list quality and sending practices. High bounce rates cause more of your emails—even to valid addresses—to land in spam. Persistent high bounce rates can lead to your sending IP being blacklisted, making it extremely difficult to deliver any emails.',
|
||||
},
|
||||
{
|
||||
question: 'Why are my emails bouncing suddenly?',
|
||||
answer:
|
||||
'Sudden increases in bounce rates can be caused by: sending to an old list that has gone stale (addresses expire over time), sending to a purchased list, a domain or server configuration change, your sending IP or domain being blacklisted, or reaching inbox provider sending limits. Identify the pattern—are bounces happening on specific domains or across all recipients?—then investigate the root cause.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailBounceRate() {
|
||||
return (
|
||||
@@ -11,6 +40,7 @@ export default function EmailBounceRate() {
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a good email open rate?',
|
||||
answer:
|
||||
'A good email open rate depends on your industry and email type. For B2B emails, 25-35% is considered excellent. For B2C marketing emails, 20-30% is strong. Overall industry averages range from 20-30%, though Apple Mail Privacy Protection has inflated reported rates by 5-15 percentage points since 2021. Compare against your own historical data rather than industry averages alone.',
|
||||
},
|
||||
{
|
||||
question: 'What are average B2B email open rates?',
|
||||
answer:
|
||||
'Average B2B email open rates typically range from 20-30%, with top-performing campaigns achieving 35-45%. B2B emails tend to outperform B2C because recipients opted in for professional reasons and have higher intent. Industry-specific rates vary: SaaS averages 22-28%, financial services 20-25%, and professional services 25-30%. Transactional B2B emails like invoices and notifications often exceed 50%.',
|
||||
},
|
||||
{
|
||||
question: 'How do I improve my email open rate?',
|
||||
answer:
|
||||
'The most impactful ways to improve email open rates are: (1) Write compelling, specific subject lines under 50 characters, (2) Personalize beyond first names—use behavioral data and purchase history, (3) Send at optimal times (Tuesday–Thursday, 10am–2pm for B2B), (4) Segment your list to send relevant content to specific groups, (5) Keep your list clean by removing inactive subscribers, (6) Use a recognizable sender name, and (7) Optimize preview text to complement the subject line.',
|
||||
},
|
||||
{
|
||||
question: 'Why are my email open rates low?',
|
||||
answer:
|
||||
'Low email open rates typically result from: weak subject lines that fail to create curiosity or urgency, poor list hygiene with too many inactive subscribers dragging down averages, emails landing in spam due to deliverability or authentication issues, bad send timing, email fatigue from sending too frequently, or poor segmentation sending irrelevant content. Review each factor systematically and A/B test changes one at a time.',
|
||||
},
|
||||
{
|
||||
question: 'Does Apple Mail Privacy Protection affect open rates?',
|
||||
answer:
|
||||
'Yes. Apple Mail Privacy Protection (MPP), launched in iOS 15 in 2021, pre-loads email images and tracking pixels, registering "opens" even if the user never actually read the email. This inflates open rates by 5-15 percentage points across the industry. To adapt, focus on click-through rates and conversions as more reliable engagement metrics, and segment by email client to understand true engagement patterns.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailOpenRate() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="Email Open Rate: Benchmarks, Strategies & Best Practices"
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
title="Email Open Rates: Industry Benchmarks & How to Improve Yours"
|
||||
description="Learn what affects email open rates, industry benchmarks by sector, B2B vs B2C averages, and proven tactics to improve your open rates. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -126,6 +156,86 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* B2B Email Open Rates */}
|
||||
<section id="b2b-email-open-rates" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">B2B Email Open Rates</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
B2B (business-to-business) emails consistently outperform B2C benchmarks. Recipients opted in because of
|
||||
genuine professional need, making them more engaged audiences.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">B2B Industry</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Average Open Rate</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Top Performers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 bg-white">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">SaaS / Technology</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">22–28%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">35%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Professional Services</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">25–32%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">40%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Financial Services</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">20–26%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">33%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Agency / Marketing</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">18–24%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">30%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Transactional / Receipts</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">45–65%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">70%+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-6">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B-Specific Tactics</h3>
|
||||
<ul className="space-y-2 text-sm text-neutral-700">
|
||||
<li>• Send Tuesday–Thursday, 10am–2pm in recipient's timezone</li>
|
||||
<li>• Use company/product name in subject line for recognition</li>
|
||||
<li>• Personalize with job title, industry, or company size</li>
|
||||
<li>• Reference specific pain points relevant to their role</li>
|
||||
<li>• Keep frequency lower—1–2 times per month for cold lists</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B vs B2C Differences</h3>
|
||||
<ul className="space-y-2 text-sm text-neutral-700">
|
||||
<li>• B2B decisions are more considered—longer subject lines acceptable</li>
|
||||
<li>• B2B audiences check email less frequently on weekends</li>
|
||||
<li>• B2B recipients share inboxes with colleagues, affecting metrics</li>
|
||||
<li>• B2B unsubscribe rates are typically lower (0.1–0.3% vs 0.3–0.5%)</li>
|
||||
<li>• B2B transactional emails (invoices, updates) have much higher opens</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="Segment Transactional vs Marketing">
|
||||
<p>
|
||||
Keep transactional emails (invoices, notifications, password resets) in separate campaigns from marketing
|
||||
emails. Transactional emails typically achieve 40–60% open rates, which will skew your marketing
|
||||
benchmarks if mixed together.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* Factors Affecting */}
|
||||
<section id="factors" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function GuidesIndex() {
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Guides+for+Developers&tag=Guide', alt: 'Plunk Guides', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DKIM?',
|
||||
answer:
|
||||
'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a cryptographic digital signature to your outgoing emails. It allows receiving mail servers to verify that an email was genuinely sent from your domain and that its content was not altered in transit, improving deliverability and protecting against email spoofing.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DKIM key?',
|
||||
answer:
|
||||
'A DKIM key is a cryptographic key pair used to sign and verify emails. The private key is stored securely on your mail server and used to sign outgoing emails. The public key is published as a TXT record in your domain\'s DNS under selector._domainkey.yourdomain.com and is used by receiving servers to verify the signature. You should never share or expose your DKIM private key.',
|
||||
},
|
||||
{
|
||||
question: 'How do I set up DKIM?',
|
||||
answer:
|
||||
'Setting up DKIM involves three steps: (1) Generate a DKIM key pair for your domain—most email providers do this automatically, (2) Publish the public key as a TXT record in your DNS at selector._domainkey.yourdomain.com, and (3) Configure your mail server or email service provider to sign outgoing emails with the private key. Platforms like Plunk handle all of this automatically when you add your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between DKIM and SPF?',
|
||||
answer:
|
||||
'SPF (Sender Policy Framework) verifies that the sending mail server is authorized to send email for your domain by checking its IP address against your DNS record. DKIM adds a cryptographic signature to the email that verifies the content has not been altered in transit. They are complementary: SPF validates the server identity, DKIM validates the message integrity. Using both together—along with DMARC—provides the strongest email authentication.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if DKIM fails?',
|
||||
answer:
|
||||
'If DKIM verification fails, the receiving mail server may treat the email as suspicious. Depending on your DMARC policy, failed DKIM emails might be delivered normally (p=none), sent to spam (p=quarantine), or completely rejected (p=reject). Persistent DKIM failures damage your sender reputation and reduce email deliverability. Common causes include misconfigured DNS records, expired keys, or email modification during forwarding.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsDKIM() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="What is DKIM? Email Authentication Explained"
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) works, what a DKIM key is, and how to set it up to protect your emails from spoofing and improve deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -300,6 +330,119 @@ export default function WhatIsDKIM() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* DKIM vs SPF */}
|
||||
<section id="dkim-vs-spf" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM vs SPF: What's the Difference?</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
DKIM and SPF are both email authentication protocols, but they work differently and check different things:
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">Feature</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">DKIM</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">SPF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 bg-white">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">What it validates</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Email content integrity</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Sending server authorization</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">How it works</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Cryptographic signature in headers</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">IP address check against DNS list</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Survives forwarding</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (if content unchanged)</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">No (forwarded IP changes)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">DNS record type</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">TXT at selector._domainkey.*</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">TXT at root domain</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Required for DMARC</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="Use Both for Maximum Protection">
|
||||
<p>
|
||||
DKIM and SPF complement each other. SPF covers scenarios where DKIM can't (like forged server IPs), and DKIM
|
||||
covers forwarding scenarios where SPF breaks. Implementing both—plus DMARC—gives you complete email
|
||||
authentication coverage.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* How to Test DKIM */}
|
||||
<section id="test-dkim" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Test Your DKIM Setup</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
After setting up DKIM, verify it's working correctly using these methods:
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">1. Check the Email Headers</h3>
|
||||
<p className="text-neutral-700 mb-4">
|
||||
Send a test email and view the raw message headers. Look for the <code>DKIM-Signature</code> header and the{' '}
|
||||
<code>Authentication-Results</code> header which shows whether DKIM passed or failed:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="text"
|
||||
title="Example Authentication-Results Header"
|
||||
code={`Authentication-Results: mx.google.com;
|
||||
dkim=pass header.i=@yourdomain.com header.s=default header.b=GJwP3Qr8;
|
||||
spf=pass (google.com: domain of you@yourdomain.com designates 1.2.3.4 as permitted sender);
|
||||
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=yourdomain.com`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">2. Use DNS Lookup Tools</h3>
|
||||
<p className="text-neutral-700">
|
||||
Verify your DKIM public key is correctly published in DNS by querying your DKIM TXT record:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
title="DNS Lookup Command"
|
||||
code={`# Check your DKIM record via DNS
|
||||
dig TXT default._domainkey.yourdomain.com
|
||||
|
||||
# Or using nslookup
|
||||
nslookup -type=TXT default._domainkey.yourdomain.com`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">3. Send to Gmail and Check</h3>
|
||||
<p className="text-neutral-700">
|
||||
Send a test email to a Gmail address, then click the three-dot menu and select "Show original". The
|
||||
"Summary" at the top will show DKIM: PASS or DKIM: FAIL, confirming your setup is working.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Common DKIM Issues">
|
||||
<p>
|
||||
If DKIM fails, check that: (1) The DNS record uses the correct selector name, (2) The record hasn't been
|
||||
truncated by your DNS provider (long keys may need to be split), (3) DNS propagation is complete (can take
|
||||
up to 48 hours), and (4) Your email service is configured to sign with the correct private key.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* Related Guides */}
|
||||
<section id="related-guides" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Authentication Guides</h2>
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DMARC?',
|
||||
answer:
|
||||
'DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an email authentication protocol that builds on SPF and DKIM to protect your domain from spoofing and phishing. It tells receiving mail servers what to do when an email fails authentication checks—none (monitor), quarantine (spam), or reject (block)—and provides detailed reports about who is sending email using your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DMARC policy?',
|
||||
answer:
|
||||
'A DMARC policy (the "p=" tag) determines how receiving mail servers handle emails that fail authentication. p=none means monitor only and take no action; p=quarantine sends failed emails to spam; p=reject completely blocks failed emails. Start with p=none to gather data, then progressively tighten to quarantine and reject as you confirm all your legitimate senders are properly authenticated.',
|
||||
},
|
||||
{
|
||||
question: 'What does p=reject mean in DMARC?',
|
||||
answer:
|
||||
'p=reject is the strictest DMARC policy. Emails that fail DMARC authentication are completely rejected and not delivered to the recipient. This provides maximum protection against phishing and spoofing, but requires that all your legitimate email sources (newsletters, CRMs, transactional tools) are properly configured with SPF and DKIM before enabling it.',
|
||||
},
|
||||
{
|
||||
question: 'How do I add a DMARC record?',
|
||||
answer:
|
||||
'Add a TXT record in your DNS with the name _dmarc.yourdomain.com. Start with a monitoring-only value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com. This records email activity without affecting delivery. After reviewing reports for 2-4 weeks and confirming your SPF and DKIM setup, gradually strengthen to p=quarantine then p=reject.',
|
||||
},
|
||||
{
|
||||
question: 'Is DMARC required for email deliverability?',
|
||||
answer:
|
||||
'Since February 2024, Google and Yahoo require DMARC authentication (with at least p=none) for bulk senders sending more than 5,000 emails per day to Gmail or Yahoo addresses. While not strictly required for smaller senders, implementing DMARC is strongly recommended for all domains to improve deliverability, prevent phishing, and meet industry best practices.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsDMARC() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="What is DMARC? Email Policy & Reporting Explained"
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
title="What is DMARC? Policy, Setup & Reporting Explained"
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain. Understand DMARC policies (none, quarantine, reject), how to set up a DMARC record, and read reports."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -2,6 +2,35 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is SPF in email?',
|
||||
answer:
|
||||
'SPF (Sender Policy Framework) is an email authentication protocol that lets domain owners specify which mail servers are authorized to send email on behalf of their domain. It works by publishing a list of authorized IP addresses in your DNS as a TXT record. When an email arrives, the receiving server checks if the sending server\'s IP address is on your approved list.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between SPF and DKIM?',
|
||||
answer:
|
||||
'SPF verifies that the sending mail server is authorized to send from your domain by checking the server\'s IP address against your DNS record. DKIM adds a cryptographic signature to the email body and headers, verifying the content was not modified in transit. SPF validates who is sending; DKIM validates what was sent. Both work best together, and DMARC requires at least one of them to be properly configured.',
|
||||
},
|
||||
{
|
||||
question: 'How do I create an SPF record?',
|
||||
answer:
|
||||
'Add a TXT record to your domain\'s DNS at the root domain (@ or yourdomain.com) with the format: v=spf1 include:[your-email-service] ~all. Replace the include: with the SPF value from your email provider. If you use multiple email services, combine them in one record: v=spf1 include:_spf.google.com include:spf.useplunk.com ~all. You can only have one SPF record per domain.',
|
||||
},
|
||||
{
|
||||
question: 'What does ~all mean in an SPF record?',
|
||||
answer:
|
||||
'~all (tilde-all) at the end of an SPF record is a "soft fail" qualifier, meaning emails from unlisted servers should be accepted but flagged as potentially suspicious. The alternative -all (hard fail) completely rejects emails from unlisted servers. Most experts recommend ~all for initial setup to avoid blocking legitimate emails. Never use +all—it passes all emails and completely defeats SPF\'s purpose.',
|
||||
},
|
||||
{
|
||||
question: 'Why is SPF failing even though I set it up correctly?',
|
||||
answer:
|
||||
'Common SPF failure causes: (1) You have multiple SPF records—you can only have one, combine all senders into a single record, (2) You exceeded the 10 DNS lookup limit—each include: counts as one lookup, (3) You added a new email service but forgot to add its SPF include, (4) DNS propagation is still in progress—can take up to 48 hours, (5) Your email is being forwarded, which changes the sending IP and breaks SPF (use DKIM too to handle forwarding).',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsSPF() {
|
||||
return (
|
||||
@@ -11,6 +40,7 @@ export default function WhatIsSPF() {
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {motion} from 'framer-motion';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import {DASHBOARD_URI, LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||
import React from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Artur from '../../public/assets/artur.png';
|
||||
import Joe from '../../public/assets/joe.png';
|
||||
import Noah from '../../public/assets/noah.png';
|
||||
@@ -154,6 +155,23 @@ const tickerItems = [
|
||||
export default function Index() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk — Open-Source Transactional Email Platform"
|
||||
description="Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits."
|
||||
openGraph={{
|
||||
title: 'Plunk — Open-Source Transactional Email Platform',
|
||||
description:
|
||||
'Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.useplunk.com/api/og?title=The+Open-Source+Email+Platform',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk — The Open-Source Email Platform',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Script
|
||||
id={`faq-schema-index`}
|
||||
type="application/ld+json"
|
||||
@@ -450,7 +468,7 @@ export default function Index() {
|
||||
<Link
|
||||
href={`/vs/${c.slug}`}
|
||||
className={
|
||||
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
|
||||
'group flex items-center justify-between gap-6 py-5 transition-colors hover:bg-neutral-50 sm:py-7'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center gap-6 sm:gap-10'}>
|
||||
@@ -463,28 +481,25 @@ export default function Index() {
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={
|
||||
'text-4xl font-bold tracking-[-0.03em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-6xl lg:text-7xl'
|
||||
'text-3xl font-semibold tracking-[-0.025em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-4xl lg:text-5xl'
|
||||
}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'flex items-center gap-4'}>
|
||||
<div className={'flex items-center gap-5'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={
|
||||
'hidden text-xs uppercase tracking-[0.18em] text-neutral-500 transition group-hover:text-neutral-900 sm:inline'
|
||||
'hidden text-[11px] uppercase tracking-[0.18em] text-neutral-400 transition group-hover:text-neutral-900 sm:inline'
|
||||
}
|
||||
>
|
||||
vs Plunk
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
'flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 text-neutral-900 transition group-hover:border-neutral-900 group-hover:bg-neutral-900 group-hover:text-white sm:h-14 sm:w-14'
|
||||
}
|
||||
>
|
||||
<ArrowUpRight className={'h-5 w-5'} strokeWidth={2} />
|
||||
</span>
|
||||
<ArrowUpRight
|
||||
className={'h-5 w-5 text-neutral-400 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-neutral-900 sm:h-6 sm:w-6'}
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.li>
|
||||
@@ -498,8 +513,8 @@ export default function Index() {
|
||||
<SectionHeader
|
||||
number={'03'}
|
||||
label={'The Problem'}
|
||||
title={"Most email tools weren't built to scale."}
|
||||
subtitle={'Three things Plunk gets right, out of the box.'}
|
||||
title={'Simple to start. Serious at scale.'}
|
||||
subtitle={'Easy enough for a side project. Ready for the business it becomes.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-20 lg:gap-28'}>
|
||||
@@ -562,7 +577,7 @@ export default function Index() {
|
||||
subtitle={'Every Plunk install ships with the full platform — no upsell pages, no locked modules.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:auto-rows-[17rem] lg:grid-cols-3'}>
|
||||
{features.map((feature, index) => {
|
||||
const highlighted = feature.feature;
|
||||
return (
|
||||
@@ -574,12 +589,14 @@ export default function Index() {
|
||||
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
highlighted
|
||||
? 'flex min-h-72 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
|
||||
? 'flex min-h-80 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-900 bg-neutral-900 p-10 text-white sm:col-span-2 lg:row-span-2 lg:p-12'
|
||||
: 'flex min-h-72 flex-col justify-between overflow-hidden rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
|
||||
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>
|
||||
{highlighted ? <Workflow className="h-10 w-10" strokeWidth={1.25} /> : feature.icon}
|
||||
</div>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={`text-[11px] uppercase tracking-[0.18em] ${
|
||||
@@ -592,16 +609,20 @@ export default function Index() {
|
||||
<div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={`mt-10 text-2xl font-bold tracking-[-0.025em] ${
|
||||
highlighted ? 'text-white' : 'text-neutral-900'
|
||||
}`}
|
||||
className={
|
||||
highlighted
|
||||
? 'text-4xl font-extrabold leading-[0.95] tracking-[-0.035em] text-white sm:text-5xl lg:text-6xl'
|
||||
: 'mt-10 text-2xl font-bold tracking-[-0.025em] text-neutral-900'
|
||||
}
|
||||
>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p
|
||||
className={`mt-3 text-sm leading-relaxed ${
|
||||
highlighted ? 'text-neutral-300' : 'text-neutral-600'
|
||||
}`}
|
||||
className={
|
||||
highlighted
|
||||
? 'mt-5 max-w-md text-base leading-relaxed text-neutral-300 sm:text-lg'
|
||||
: 'mt-3 text-sm leading-relaxed text-neutral-600'
|
||||
}
|
||||
>
|
||||
{feature.description}
|
||||
</p>
|
||||
@@ -619,60 +640,73 @@ export default function Index() {
|
||||
number={'05'}
|
||||
label={'Data Model'}
|
||||
title={'One contact,'}
|
||||
titleAccent={'complete history.'}
|
||||
titleAccent={'complete history.'}
|
||||
subtitle={
|
||||
'Every interaction flows into a single contact record. Transactional, campaign and workflow events — one source of truth.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto mt-20 max-w-5xl'}>
|
||||
<div className={'grid gap-6 lg:grid-cols-3'}>
|
||||
<div className={'mx-auto mt-20 max-w-3xl'}>
|
||||
<div className={'grid gap-4 sm:grid-cols-2'}>
|
||||
{[
|
||||
{
|
||||
icon: <Send className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Send className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Transactional',
|
||||
sub: 'Receipts, resets',
|
||||
},
|
||||
{
|
||||
icon: <Megaphone className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Megaphone className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Campaigns',
|
||||
sub: 'Newsletters, launches',
|
||||
},
|
||||
{
|
||||
icon: <Workflow className="h-7 w-7" strokeWidth={1.5} />,
|
||||
icon: <Workflow className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Workflows',
|
||||
sub: 'Onboarding, drip sequences',
|
||||
},
|
||||
{
|
||||
icon: <Inbox className="h-5 w-5" strokeWidth={1.5} />,
|
||||
title: 'Inbound',
|
||||
sub: 'Replies, support',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: 0.1 + i * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'rounded-[24px] border border-neutral-200 bg-white p-8 text-left'}
|
||||
transition={{duration: 0.5, delay: 0.1 + i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex items-center gap-4 rounded-2xl border border-neutral-200 bg-white p-5'}
|
||||
>
|
||||
<div className={'text-neutral-900'}>{item.icon}</div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
<div
|
||||
className={
|
||||
'flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-100 text-neutral-900'
|
||||
}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>{item.sub}</p>
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className={'min-w-0'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-base font-semibold tracking-[-0.01em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-0.5 text-xs text-neutral-600'}>{item.sub}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={'relative my-12 hidden lg:block'}>
|
||||
<svg className={'mx-auto h-28 w-full'} viewBox="0 0 600 120" preserveAspectRatio="none">
|
||||
<div className={'relative my-10 hidden justify-center sm:flex'}>
|
||||
<svg width="40" height="80" viewBox="0 0 40 80" aria-hidden>
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#a3a3a3" />
|
||||
</marker>
|
||||
</defs>
|
||||
<motion.path
|
||||
d="M 100 0 Q 150 60, 250 110"
|
||||
d="M 20 0 L 20 70"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
@@ -680,29 +714,7 @@ export default function Index() {
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 300 0 L 300 110"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 500 0 Q 450 60, 350 110"
|
||||
stroke="#d4d4d4"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
initial={{pathLength: 0}}
|
||||
whileInView={{pathLength: 1}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 1.2, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||
transition={{duration: 0.9, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -713,6 +725,7 @@ 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'}>
|
||||
@@ -827,7 +840,7 @@ export default function Index() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={'mt-12 flex justify-center'}>
|
||||
<div className={'mt-10 flex justify-center'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.02}}
|
||||
whileTap={{scale: 0.98}}
|
||||
@@ -944,7 +957,7 @@ export default function Index() {
|
||||
subtitle={'No hyperbole. Just the people building products on Plunk.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
<div className={'mt-16 grid gap-5 sm:grid-cols-2 lg:auto-rows-[17rem] lg:grid-cols-3'}>
|
||||
{testimonials.map((t, i) => {
|
||||
const highlighted = t.featured;
|
||||
return (
|
||||
@@ -956,7 +969,7 @@ export default function Index() {
|
||||
transition={{duration: 0.5, delay: i * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
highlighted
|
||||
? 'flex min-h-72 flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
|
||||
? 'flex min-h-80 flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-10 text-white sm:col-span-2 lg:row-span-2 lg:p-12'
|
||||
: 'flex min-h-72 flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8'
|
||||
}
|
||||
>
|
||||
@@ -964,28 +977,34 @@ export default function Index() {
|
||||
style={highlighted ? {fontFamily: 'var(--font-display)'} : undefined}
|
||||
className={
|
||||
highlighted
|
||||
? 'text-xl font-medium leading-[1.2] tracking-[-0.015em] text-white sm:text-2xl'
|
||||
? 'text-3xl font-medium leading-[1.1] tracking-[-0.02em] text-white sm:text-4xl lg:text-5xl'
|
||||
: 'text-sm leading-relaxed text-neutral-700'
|
||||
}
|
||||
>
|
||||
“{t.testimonial}”
|
||||
</blockquote>
|
||||
<figcaption className={'mt-6 flex items-center gap-3'}>
|
||||
<figcaption className={`mt-6 flex items-center ${highlighted ? 'gap-4' : 'gap-3'}`}>
|
||||
<div
|
||||
className={`relative h-10 w-10 overflow-hidden rounded-full ${
|
||||
highlighted ? 'border border-neutral-700' : ''
|
||||
className={`relative overflow-hidden rounded-full ${
|
||||
highlighted ? 'h-12 w-12 border border-neutral-700' : 'h-10 w-10'
|
||||
}`}
|
||||
>
|
||||
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={`text-xs font-semibold ${highlighted ? 'text-white' : 'text-neutral-900'}`}>
|
||||
<div
|
||||
className={
|
||||
highlighted
|
||||
? 'text-sm font-semibold text-white'
|
||||
: 'text-xs font-semibold text-neutral-900'
|
||||
}
|
||||
>
|
||||
{t.author}
|
||||
</div>
|
||||
<div
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={`mt-0.5 text-[10px] uppercase tracking-[0.18em] ${
|
||||
highlighted ? 'text-neutral-400' : 'text-neutral-500'
|
||||
className={`mt-0.5 uppercase tracking-[0.18em] ${
|
||||
highlighted ? 'text-[11px] text-neutral-400' : 'text-[10px] text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{t.role}
|
||||
|
||||
@@ -79,6 +79,14 @@ export default function Pricing() {
|
||||
title: 'Plunk Pricing | The Open-Source Email Platform',
|
||||
description:
|
||||
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month. No hidden fees.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.useplunk.com/api/og?title=Simple%2C+Honest+Pricing&description=%240.001+per+email.+No+contact+limits.+Free+to+start.',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk Pricing',
|
||||
},
|
||||
],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Key, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
const COMMON_SELECTORS = [
|
||||
{label: 'Google Workspace', value: 'google'},
|
||||
{label: 'Microsoft 365', value: 'selector1'},
|
||||
{label: 'Microsoft 365 (2)', value: 'selector2'},
|
||||
{label: 'Mailchimp', value: 'k1'},
|
||||
{label: 'Mailchimp (2)', value: 'k2'},
|
||||
{label: 'Postmark', value: 'pm'},
|
||||
{label: 'SendGrid', value: 'sendgrid'},
|
||||
{label: 'Amazon SES', value: 'ses'},
|
||||
{label: 'Mailjet', value: 'mailjet'},
|
||||
{label: 'Zoho', value: 'zoho'},
|
||||
{label: 'Generic', value: 'mail'},
|
||||
{label: 'Generic (2)', value: 'default'},
|
||||
{label: 'Generic (3)', value: 's1'},
|
||||
{label: 'Generic (4)', value: 's2'},
|
||||
];
|
||||
|
||||
interface DkimIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DkimAnalysis {
|
||||
keyType: string;
|
||||
isRevoked: boolean;
|
||||
isTesting: boolean;
|
||||
publicKeySnippet: string;
|
||||
issues: DkimIssue[];
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
function analyzeDkim(tags: Record<string, string>): DkimAnalysis {
|
||||
const keyType = tags['k'] ?? 'rsa';
|
||||
const publicKey = tags['p'] ?? '';
|
||||
const flags = tags['t'] ?? '';
|
||||
const isRevoked = publicKey === '';
|
||||
const isTesting = flags.includes('y');
|
||||
const issues: DkimIssue[] = [];
|
||||
|
||||
if (isRevoked) {
|
||||
issues.push({type: 'error', label: 'DKIM key has been revoked', detail: 'p= is empty, which signals that this key has been intentionally revoked. Email signed with this selector will fail DKIM validation. Publish a new key.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Public key is present', detail: 'A valid public key is published for this selector.'});
|
||||
}
|
||||
|
||||
if (isTesting) {
|
||||
issues.push({type: 'warning', label: 'Key is in testing mode (t=y)', detail: 'Testing mode means receiving servers should not reject messages that fail DKIM, even if the signature is invalid. Remove t=y to enable full enforcement.'});
|
||||
}
|
||||
|
||||
if (keyType === 'rsa') {
|
||||
issues.push({type: 'pass', label: 'Key type: RSA', detail: 'RSA is the standard and widely-supported DKIM key type.'});
|
||||
} else if (keyType === 'ed25519') {
|
||||
issues.push({type: 'pass', label: 'Key type: Ed25519', detail: 'Ed25519 provides strong security with smaller key sizes. Ensure your sending infrastructure supports it, as some older servers may not.'});
|
||||
}
|
||||
|
||||
const publicKeySnippet = publicKey.length > 32 ? `${publicKey.slice(0, 32)}…` : publicKey;
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {keyType, isRevoked, isTesting, publicKeySnippet, issues, grade};
|
||||
}
|
||||
|
||||
function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'DKIM key is active'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'DKIM has configuration issues'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Invalid', sub: 'DKIM key is revoked or invalid'},
|
||||
};
|
||||
const {cls, label, sub} = map[grade];
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
v: 'DKIM version',
|
||||
k: 'Key type (rsa or ed25519)',
|
||||
p: 'Base64-encoded public key',
|
||||
t: 'Flags (y=testing, s=strict service)',
|
||||
s: 'Service type restriction',
|
||||
h: 'Acceptable hash algorithms',
|
||||
n: 'Notes (human-readable)',
|
||||
};
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DKIM?',
|
||||
answer: 'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a digital signature to outgoing email. The signature is verified by receiving servers using a public key published in your DNS. If the signature matches, the email is confirmed to have originated from your domain and has not been tampered with in transit.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DKIM selector?',
|
||||
answer: 'A DKIM selector is a label that identifies which DKIM key to use when there are multiple keys for a domain. Selectors are arbitrary strings chosen by the sending service (e.g., "google" for Google Workspace, "selector1" for Microsoft 365, "k1" for Mailchimp). The DKIM record is published at {selector}._domainkey.{domain}.',
|
||||
},
|
||||
{
|
||||
question: 'Where do I find my DKIM selector?',
|
||||
answer: 'Your DKIM selector is provided by your email sending service. In Google Workspace, it\'s typically "google". In Microsoft 365, it\'s "selector1" and "selector2". In Mailchimp, it\'s "k1". Check your email provider\'s DNS setup guide or look in the DKIM signature of a sent email (the "s=" tag in the DKIM-Signature header).',
|
||||
},
|
||||
{
|
||||
question: 'Why is my DKIM key revoked?',
|
||||
answer: 'A DKIM key is revoked by publishing a DKIM record with an empty p= value. This is intentional and signals that the key should no longer be used. Reasons include key rotation, key compromise, or switching email providers. If you didn\'t intentionally revoke the key, check your DNS records and publish a new DKIM key.',
|
||||
},
|
||||
{
|
||||
question: 'Should I use RSA or Ed25519 for DKIM?',
|
||||
answer: 'RSA (2048-bit) is the safest choice for maximum compatibility, as it is supported by all email providers. Ed25519 offers equivalent security with much smaller keys but is not supported by some older mail servers. A best practice is to publish both an RSA key and an Ed25519 key with different selectors, letting modern servers prefer Ed25519.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface DkimLookupResult {
|
||||
domain: string;
|
||||
selector: string;
|
||||
found: boolean;
|
||||
record: string | null;
|
||||
tags: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDkim(domain: string, selector: string): Promise<DkimLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
const sel = selector.trim().toLowerCase();
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`${sel}._domainkey.${clean}`)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? [])
|
||||
.map((a: DnsAnswer) => cleanTxt(a.data))
|
||||
.filter(r => r.startsWith('v=DKIM1') || r.includes('k=') || r.includes('p='));
|
||||
if (records.length === 0) return {domain: clean, selector: sel, found: false, record: null, tags: {}};
|
||||
const record: string = records[0]!;
|
||||
const tags: Record<string, string> = {};
|
||||
record.split(';').forEach(part => {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = part.slice(0, eqIdx).trim();
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
if (key) tags[key] = value;
|
||||
}
|
||||
});
|
||||
return {domain: clean, selector: sel, found: true, record, tags};
|
||||
} catch {
|
||||
return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function DkimCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [selector, setSelector] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DkimLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DkimAnalysis | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setAnalysis(null);
|
||||
|
||||
try {
|
||||
const data = await lookupDkim(domain, selector);
|
||||
setResult(data);
|
||||
if (data.found && data.tags) {
|
||||
setAnalysis(analyzeDkim(data.tags));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DKIM Record Checker | Free DKIM Lookup & Validator | Plunk"
|
||||
description="Free DKIM record checker. Look up your DKIM public key by domain and selector, validate the record, and get advice to fix misconfigurations."
|
||||
canonical="https://www.useplunk.com/tools/dkim-checker"
|
||||
openGraph={{
|
||||
title: 'DKIM Record Checker | Free DKIM Lookup & Validator | Plunk',
|
||||
description: 'Free DKIM record checker. Look up and validate your DKIM key by domain and selector.',
|
||||
url: 'https://www.useplunk.com/tools/dkim-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DKIM+Record+Checker&tag=Tool', alt: 'Plunk DKIM Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-06 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DKIM record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up your domain's DKIM public key by selector. Verify the key is active, understand the
|
||||
configuration, and get advice if something looks wrong.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Key className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DKIM record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="selector" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
DKIM selector <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="selector"
|
||||
type="text"
|
||||
value={selector}
|
||||
onChange={e => setSelector(e.target.value)}
|
||||
placeholder="google"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>
|
||||
Not sure? Try a common selector below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Common selectors */}
|
||||
<div>
|
||||
<p className={'mb-2 text-xs font-medium text-neutral-500'}>Common selectors</p>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
{COMMON_SELECTORS.map(s => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
onClick={() => setSelector(s.value)}
|
||||
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
|
||||
selector === s.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white'
|
||||
: 'border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
{s.label} <span style={{fontFamily: 'var(--font-mono)'}} className={'opacity-60'}>({s.value})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Key className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DKIM record…' : 'Check DKIM record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-amber-100 bg-amber-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-600'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-amber-900'}>No DKIM record found</p>
|
||||
<p className={'mt-1 text-sm text-amber-800'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No DKIM record was found at `}
|
||||
{!result.error && (
|
||||
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-xs'}>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
)}
|
||||
</p>
|
||||
{!result.error && (
|
||||
<p className={'mt-2 text-sm text-amber-700'}>
|
||||
Check that you are using the correct selector. If your email provider has given you a specific selector, use that. If the record still doesn't appear, DNS propagation may still be in progress.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Lookup host */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Record found at
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-sm font-medium text-neutral-700'}
|
||||
>
|
||||
{result.selector}._domainkey.{result.domain}
|
||||
</code>
|
||||
|
||||
{result.record && (
|
||||
<>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 mt-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record.length > 200
|
||||
? `${result.record.slice(0, 200)}… [${result.record.length - 200} more characters]`
|
||||
: result.record}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{Object.keys(result.tags).length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => {
|
||||
const displayValue = key === 'p' && value.length > 48
|
||||
? `${value.slice(0, 48)}… [${value.length - 48} more chars]`
|
||||
: value || '(empty — key revoked)';
|
||||
return (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 flex-col gap-0.5'}>
|
||||
<div className={'flex items-center gap-2'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DKIM explained'}
|
||||
title={'How DKIM signing works.'}
|
||||
subtitle={'DKIM proves your email was sent by your domain and wasn\'t altered in transit.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Cryptographic signature',
|
||||
body: 'Your sending server signs each email using a private key. The corresponding public key is published in DNS. Receiving servers verify the signature to confirm authenticity.',
|
||||
},
|
||||
{
|
||||
title: 'Selector system',
|
||||
body: 'Each DKIM key is identified by a selector. You can have multiple selectors (and keys) per domain, allowing key rotation and multiple sending providers at the same time.',
|
||||
},
|
||||
{
|
||||
title: 'Tamper detection',
|
||||
body: 'The DKIM signature covers specific email headers and the body. If the email is modified in transit, the signature breaks and DKIM fails — protecting against content manipulation.',
|
||||
},
|
||||
{
|
||||
title: 'Key rotation',
|
||||
body: 'Best practice is to rotate DKIM keys annually. Publish the new key under a different selector, update your sending infrastructure, then revoke the old key by setting p= to empty.',
|
||||
},
|
||||
{
|
||||
title: 'DKIM alone is not enough',
|
||||
body: 'Like SPF, DKIM authentication alone doesn\'t protect the visible From header. You need DMARC to enforce authentication policies and protect against spoofing.',
|
||||
},
|
||||
{
|
||||
title: '2048-bit RSA minimum',
|
||||
body: '1024-bit RSA keys are considered insecure. Use at least 2048-bit RSA or switch to Ed25519, which provides equivalent security with much smaller keys.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Sign every email. Reach the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk configures DKIM signing automatically and guides you through setting up SPF and DMARC for your
|
||||
domain.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dkim"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DKIM?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dkim-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, ShieldCheck, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface DmarcIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DmarcAnalysis {
|
||||
policy: string;
|
||||
subPolicy: string | null;
|
||||
pct: number;
|
||||
hasRua: boolean;
|
||||
hasRuf: boolean;
|
||||
adkim: string;
|
||||
aspf: string;
|
||||
issues: DmarcIssue[];
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
function analyzeDmarc(tags: Record<string, string>): DmarcAnalysis {
|
||||
const policy = tags['p'] ?? '';
|
||||
const subPolicy = tags['sp'] ?? null;
|
||||
const pct = parseInt(tags['pct'] ?? '100', 10);
|
||||
const hasRua = Boolean(tags['rua']);
|
||||
const hasRuf = Boolean(tags['ruf']);
|
||||
const adkim = tags['adkim'] ?? 'r';
|
||||
const aspf = tags['aspf'] ?? 'r';
|
||||
const issues: DmarcIssue[] = [];
|
||||
|
||||
// Policy
|
||||
if (!policy) {
|
||||
issues.push({type: 'error', label: 'Missing policy (p=)', detail: 'The p= tag is required. Set p=none to monitor, p=quarantine to send to spam, or p=reject to block.'});
|
||||
} else if (policy === 'none') {
|
||||
issues.push({type: 'warning', label: 'Policy is p=none (monitoring only)', detail: 'p=none means DMARC failures are reported but no action is taken. Upgrade to p=quarantine then p=reject once you confirm legitimate mail passes.'});
|
||||
} else if (policy === 'quarantine') {
|
||||
issues.push({type: 'warning', label: 'Policy is p=quarantine', detail: 'Failing messages are sent to spam/junk. Consider upgrading to p=reject for full protection.'});
|
||||
} else if (policy === 'reject') {
|
||||
issues.push({type: 'pass', label: 'Policy is p=reject — maximum protection', detail: 'Failing messages are rejected outright. This is the strongest DMARC policy.'});
|
||||
}
|
||||
|
||||
// Percentage
|
||||
if (pct < 100) {
|
||||
issues.push({type: 'warning', label: `Policy applies to only ${pct}% of messages`, detail: `pct=${pct} means DMARC enforcement only applies to ${pct}% of failing mail. Set pct=100 for full enforcement.`});
|
||||
} else if (policy && policy !== 'none') {
|
||||
issues.push({type: 'pass', label: 'Policy applies to 100% of messages', detail: 'DMARC enforcement is fully deployed.'});
|
||||
}
|
||||
|
||||
// Aggregate reports
|
||||
if (!hasRua) {
|
||||
issues.push({type: 'warning', label: 'No aggregate reporting (rua= missing)', detail: 'Without rua=, you receive no DMARC aggregate reports. Add rua=mailto:dmarc@yourdomain.com or use a DMARC reporting service to monitor your authentication results.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Aggregate reports configured (rua=)', detail: 'You will receive DMARC aggregate reports to monitor SPF and DKIM alignment.'});
|
||||
}
|
||||
|
||||
// Alignment
|
||||
if (adkim === 's') {
|
||||
issues.push({type: 'pass', label: 'DKIM alignment: strict', detail: 'Strict DKIM alignment requires the d= domain in the DKIM signature to exactly match the From domain.'});
|
||||
}
|
||||
if (aspf === 's') {
|
||||
issues.push({type: 'pass', label: 'SPF alignment: strict', detail: 'Strict SPF alignment requires the envelope sender domain to exactly match the From domain.'});
|
||||
}
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {policy, subPolicy, pct, hasRua, hasRuf, adkim, aspf, issues, grade};
|
||||
}
|
||||
|
||||
function PolicyBadge({policy}: {policy: string}) {
|
||||
if (policy === 'reject') {
|
||||
return <span className={'rounded-full bg-green-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-green-700 border border-green-200'}>reject</span>;
|
||||
}
|
||||
if (policy === 'quarantine') {
|
||||
return <span className={'rounded-full bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-amber-700 border border-amber-200'}>quarantine</span>;
|
||||
}
|
||||
if (policy === 'none') {
|
||||
return <span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-neutral-600 border border-neutral-300'}>none</span>;
|
||||
}
|
||||
return <span className={'rounded-full bg-red-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-red-700 border border-red-200'}>missing</span>;
|
||||
}
|
||||
|
||||
function GradeBadge({grade, policy}: {grade: 'pass' | 'warning' | 'fail'; policy: string}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required'},
|
||||
};
|
||||
const {cls, label} = map[grade];
|
||||
const sub = policy === 'reject' ? 'Full DMARC protection' : policy === 'quarantine' ? 'Partial protection' : policy === 'none' ? 'Monitoring only — no enforcement' : 'DMARC not enforcing';
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TAG_DESCRIPTIONS: Record<string, string> = {
|
||||
v: 'DMARC version',
|
||||
p: 'Domain policy for failing messages',
|
||||
sp: 'Subdomain policy override',
|
||||
pct: 'Percentage of messages subject to policy',
|
||||
rua: 'Aggregate report recipients',
|
||||
ruf: 'Forensic report recipients',
|
||||
adkim: 'DKIM alignment mode (r=relaxed, s=strict)',
|
||||
aspf: 'SPF alignment mode (r=relaxed, s=strict)',
|
||||
fo: 'Failure reporting options',
|
||||
rf: 'Forensic report format',
|
||||
ri: 'Reporting interval (seconds)',
|
||||
};
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DMARC?',
|
||||
answer: 'DMARC (Domain-based Message Authentication, Reporting & Conformance) is an email authentication policy that builds on SPF and DKIM. It tells receiving mail servers what to do when an email fails authentication — none (monitor), quarantine (spam folder), or reject (block). DMARC also enables reporting so you can see who is sending email from your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between p=none, quarantine, and reject?',
|
||||
answer: 'p=none means DMARC is in monitoring mode — failures are reported but emails are still delivered. p=quarantine instructs receiving servers to put failing messages in the spam/junk folder. p=reject instructs servers to reject failing messages entirely. The recommended path is to start at p=none, review reports, then progress to quarantine and finally reject.',
|
||||
},
|
||||
{
|
||||
question: 'What are DMARC aggregate reports?',
|
||||
answer: 'Aggregate reports (rua=) are XML reports sent by receiving mail servers summarising how many messages passed or failed SPF and DKIM for your domain. They help you identify all sources sending on your behalf, catch misconfigurations, and detect spoofing attempts. Use a DMARC reporting service to parse and visualise these reports.',
|
||||
},
|
||||
{
|
||||
question: 'Why do I need DMARC if I already have SPF and DKIM?',
|
||||
answer: 'SPF and DKIM independently authenticate different aspects of an email, but neither specifies what to do when authentication fails. DMARC ties them together and enforces a policy. Without DMARC, even a domain with perfect SPF and DKIM offers no protection against spoofing of the visible From header.',
|
||||
},
|
||||
{
|
||||
question: 'What is DMARC alignment?',
|
||||
answer: 'DMARC alignment requires that the domain in a passing SPF or DKIM check matches (or aligns with) the From header domain. Relaxed alignment (r) allows subdomains; strict alignment (s) requires an exact domain match. Alignment is what connects SPF/DKIM to the From header the user sees.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface DmarcLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
record: string | null;
|
||||
tags: Record<string, string>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupDmarc(domain: string): Promise<DmarcLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_dmarc.${clean}`)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=DMARC1'));
|
||||
if (records.length === 0) return {domain: clean, found: false, record: null, tags: {}};
|
||||
const record: string = records[0]!;
|
||||
const tags: Record<string, string> = {};
|
||||
record.split(';').forEach(part => {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx > -1) {
|
||||
const key = part.slice(0, eqIdx).trim();
|
||||
const value = part.slice(eqIdx + 1).trim();
|
||||
if (key) tags[key] = value;
|
||||
}
|
||||
});
|
||||
return {domain: clean, found: true, record, tags};
|
||||
} catch {
|
||||
return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function DmarcCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<DmarcLookupResult | null>(null);
|
||||
const [analysis, setAnalysis] = useState<DmarcAnalysis | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setAnalysis(null);
|
||||
|
||||
try {
|
||||
const data = await lookupDmarc(domain);
|
||||
setResult(data);
|
||||
if (data.found && data.tags) {
|
||||
setAnalysis(analyzeDmarc(data.tags));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="DMARC Record Checker | Free DMARC Lookup & Validator | Plunk"
|
||||
description="Free DMARC record checker. Look up and validate your domain's DMARC policy, check reporting configuration, and get step-by-step advice to strengthen email security."
|
||||
canonical="https://www.useplunk.com/tools/dmarc-checker"
|
||||
openGraph={{
|
||||
title: 'DMARC Record Checker | Free DMARC Lookup & Validator | Plunk',
|
||||
description: 'Free DMARC record checker. Validate your DMARC policy and get actionable advice to protect your domain from spoofing.',
|
||||
url: 'https://www.useplunk.com/tools/dmarc-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DMARC+Record+Checker&tag=Tool', alt: 'Plunk DMARC Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-05 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
DMARC record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's DMARC record. Understand your current policy, reporting
|
||||
configuration, and get clear advice to progress toward full enforcement.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<ShieldCheck className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
DMARC record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<ShieldCheck className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up DMARC record…' : 'Check DMARC record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No DMARC record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No DMARC record was found at _dmarc.${result.domain}. Without DMARC, your domain has no enforcement policy and you won't receive authentication reports.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>_dmarc.{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900 break-all'}
|
||||
>
|
||||
{`v=DMARC1; p=none; rua=mailto:dmarc@${result.domain}`}
|
||||
</code>
|
||||
<p className={'mt-2 text-xs text-red-600'}>Start with p=none to monitor, then progress to quarantine and reject.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — _dmarc.{result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.record}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={analysis.grade} policy={analysis.policy} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Record tags
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{Object.entries(result.tags).map(([key, value]) => (
|
||||
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex min-w-0 flex-1 items-center gap-3'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'shrink-0 text-xs font-bold text-neutral-700'}>
|
||||
{key}=
|
||||
</span>
|
||||
{key === 'p' || key === 'sp' ? (
|
||||
<PolicyBadge policy={value} />
|
||||
) : (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{TAG_DESCRIPTIONS[key] && (
|
||||
<span className={'shrink-0 text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analysis */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{analysis.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'DMARC explained'}
|
||||
title={'From monitoring to full enforcement.'}
|
||||
subtitle={'DMARC is a journey. Start with none, build confidence, then enforce.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
policy: 'p=none',
|
||||
title: 'Monitor',
|
||||
body: 'Start here. DMARC is active but no action is taken on failures. Add rua= to receive aggregate reports and identify all your sending sources.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
policy: 'p=quarantine',
|
||||
title: 'Quarantine',
|
||||
body: 'Once you\'re confident all legitimate senders pass, move to quarantine. Failing messages are sent to spam, reducing spoofing impact.',
|
||||
cls: 'border-amber-300',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
policy: 'p=reject',
|
||||
title: 'Reject',
|
||||
body: 'Full enforcement. Failing messages are rejected by receiving servers. This is the goal — it completely prevents domain spoofing.',
|
||||
cls: 'border-green-400',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.step}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700'}
|
||||
>
|
||||
{item.policy}
|
||||
</code>
|
||||
</div>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Email that reaches the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk walks you through SPF, DKIM, and DMARC setup and monitors your sending reputation over time.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-dmarc"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is DMARC?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-dmarc-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, FileText, XCircle} from 'lucide-react';
|
||||
import {Button} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface AuthResult {
|
||||
mechanism: 'spf' | 'dkim' | 'dmarc';
|
||||
result: string;
|
||||
detail: string;
|
||||
pass: boolean | null;
|
||||
}
|
||||
|
||||
interface ReceivedHop {
|
||||
from: string;
|
||||
by: string;
|
||||
timestamp: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface ParsedHeaders {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
subject: string | null;
|
||||
date: string | null;
|
||||
messageId: string | null;
|
||||
replyTo: string | null;
|
||||
returnPath: string | null;
|
||||
xMailer: string | null;
|
||||
dkimSelector: string | null;
|
||||
dkimDomain: string | null;
|
||||
authResults: AuthResult[];
|
||||
hops: ReceivedHop[];
|
||||
}
|
||||
|
||||
function parseRawHeaders(raw: string): Record<string, string[]> {
|
||||
const headers: Record<string, string[]> = {};
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let currentKey = '';
|
||||
let currentValue = '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\s/.test(line) && currentKey) {
|
||||
currentValue += ' ' + line.trim();
|
||||
} else {
|
||||
if (currentKey) {
|
||||
const existing = headers[currentKey];
|
||||
if (existing) existing.push(currentValue.trim());
|
||||
else headers[currentKey] = [currentValue.trim()];
|
||||
}
|
||||
const colonIdx = line.indexOf(':');
|
||||
if (colonIdx > 0) {
|
||||
currentKey = line.slice(0, colonIdx).trim().toLowerCase();
|
||||
currentValue = line.slice(colonIdx + 1).trim();
|
||||
} else {
|
||||
currentKey = '';
|
||||
currentValue = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentKey) {
|
||||
const existing = headers[currentKey];
|
||||
if (existing) existing.push(currentValue.trim());
|
||||
else headers[currentKey] = [currentValue.trim()];
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function parseAuthResults(authHeader: string): AuthResult[] {
|
||||
const results: AuthResult[] = [];
|
||||
const lower = authHeader.toLowerCase();
|
||||
|
||||
const mechanisms: Array<'spf' | 'dkim' | 'dmarc'> = ['spf', 'dkim', 'dmarc'];
|
||||
for (const mech of mechanisms) {
|
||||
const mechIdx = lower.indexOf(mech + '=');
|
||||
if (mechIdx === -1) continue;
|
||||
|
||||
const afterMech = authHeader.slice(mechIdx + mech.length + 1);
|
||||
const resultMatch = /^(\w+)/.exec(afterMech);
|
||||
const result = resultMatch?.[1]?.toLowerCase() ?? 'unknown';
|
||||
|
||||
const pass =
|
||||
result === 'pass'
|
||||
? true
|
||||
: result === 'fail' || result === 'hardfail' || result === 'none'
|
||||
? false
|
||||
: null;
|
||||
|
||||
let detail = '';
|
||||
if (mech === 'spf') {
|
||||
const smtpMatch = /smtp\.mailfrom\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (smtpMatch?.[1]) detail = `smtp.mailfrom=${smtpMatch[1]}`;
|
||||
} else if (mech === 'dkim') {
|
||||
const headerMatch = /header\.i\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (headerMatch?.[1]) detail = `header.i=${headerMatch[1]}`;
|
||||
} else if (mech === 'dmarc') {
|
||||
const headerMatch = /header\.from\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
|
||||
if (headerMatch?.[1]) detail = `header.from=${headerMatch[1]}`;
|
||||
}
|
||||
|
||||
results.push({mechanism: mech, result, detail, pass});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseReceivedHop(receivedValue: string): ReceivedHop {
|
||||
const fromMatch = /from\s+([^\s]+)/i.exec(receivedValue);
|
||||
const byMatch = /by\s+([^\s]+)/i.exec(receivedValue);
|
||||
|
||||
const datePattern = /;\s*(.+)$/;
|
||||
const dateMatch = datePattern.exec(receivedValue);
|
||||
let timestamp: string | null = null;
|
||||
if (dateMatch?.[1]) {
|
||||
const parsed = new Date(dateMatch[1].trim());
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
timestamp = parsed.toLocaleString('en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
from: fromMatch?.[1] ?? '(unknown)',
|
||||
by: byMatch?.[1] ?? '(unknown)',
|
||||
timestamp,
|
||||
raw: receivedValue,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDkimSignature(dkimValue: string): {selector: string | null; domain: string | null} {
|
||||
const sMatch = /\bs\s*=\s*([^;\s]+)/i.exec(dkimValue);
|
||||
const dMatch = /\bd\s*=\s*([^;\s]+)/i.exec(dkimValue);
|
||||
return {selector: sMatch?.[1] ?? null, domain: dMatch?.[1] ?? null};
|
||||
}
|
||||
|
||||
function analyzeHeaders(raw: string): ParsedHeaders | null {
|
||||
if (!raw.trim()) return null;
|
||||
|
||||
const headers = parseRawHeaders(raw);
|
||||
const first = (key: string) => headers[key]?.[0] ?? null;
|
||||
|
||||
const authHeader = first('authentication-results');
|
||||
const authResults = authHeader ? parseAuthResults(authHeader) : [];
|
||||
|
||||
const receivedHeaders = headers['received'] ?? [];
|
||||
const hops = receivedHeaders.map(parseReceivedHop).reverse();
|
||||
|
||||
const dkimSig = first('dkim-signature');
|
||||
const {selector, domain} = dkimSig ? parseDkimSignature(dkimSig) : {selector: null, domain: null};
|
||||
|
||||
return {
|
||||
from: first('from'),
|
||||
to: first('to'),
|
||||
subject: first('subject'),
|
||||
date: first('date'),
|
||||
messageId: first('message-id'),
|
||||
replyTo: first('reply-to'),
|
||||
returnPath: first('return-path'),
|
||||
xMailer: first('x-mailer') ?? first('x-mailing-list') ?? null,
|
||||
dkimSelector: selector,
|
||||
dkimDomain: domain,
|
||||
authResults,
|
||||
hops,
|
||||
};
|
||||
}
|
||||
|
||||
function AuthBadge({result}: {result: string}) {
|
||||
const pass = result === 'pass';
|
||||
const fail = result === 'fail' || result === 'hardfail';
|
||||
const cls = pass
|
||||
? 'bg-green-100 text-green-700'
|
||||
: fail
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-neutral-100 text-neutral-600';
|
||||
return (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={`rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
|
||||
{result}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'Where do I find the raw email headers?',
|
||||
answer: 'In Gmail: open the email, click the three-dot menu (⋮), choose "Show original". In Outlook: open the email, click File → Properties, and copy the "Internet headers" box. In Apple Mail: with the email open, go to View → Message → All Headers. The raw headers appear at the top of the message source.',
|
||||
},
|
||||
{
|
||||
question: 'What do the Authentication-Results headers mean?',
|
||||
answer: 'Authentication-Results is added by the receiving mail server and summarises the SPF, DKIM, and DMARC check results. "pass" means the check succeeded. "fail" or "hardfail" means it failed. "none" means no record was found. If DMARC passes but SPF or DKIM fails, check that your sending domain aligns with the From header.',
|
||||
},
|
||||
{
|
||||
question: 'How do I read the Received chain?',
|
||||
answer: 'Received headers are added by each mail server the message passed through, with the most recent at the top. Reading them bottom-to-top gives you the routing path from sender to inbox. Large time gaps between hops indicate server delays or queuing. The very first Received header shows where the message originated.',
|
||||
},
|
||||
{
|
||||
question: 'Why does email land in spam even though SPF and DKIM pass?',
|
||||
answer: 'Authentication passing is necessary but not sufficient. Spam filters also consider sender reputation, IP blacklist status, content quality, engagement history, and DMARC alignment. A brand-new IP, a domain with no sending history, or content with many spam trigger words can cause filtering even with perfect authentication.',
|
||||
},
|
||||
{
|
||||
question: 'What is the DKIM-Signature header?',
|
||||
answer: 'DKIM-Signature contains a cryptographic signature applied by the sending server. The "d=" tag identifies the signing domain and "s=" is the selector used to locate the public key in DNS. Receiving servers verify the signature against the public key at <selector>._domainkey.<domain>. A mismatch or missing key causes a DKIM fail.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailHeadersPage() {
|
||||
const [raw, setRaw] = useState('');
|
||||
const [result, setResult] = useState<ParsedHeaders | null>(null);
|
||||
const [analysed, setAnalysed] = useState(false);
|
||||
|
||||
const handleAnalyze = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const parsed = analyzeHeaders(raw);
|
||||
setResult(parsed);
|
||||
setAnalysed(true);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setRaw('');
|
||||
setResult(null);
|
||||
setAnalysed(false);
|
||||
};
|
||||
|
||||
const metaFields: Array<{label: string; value: string | null}> = result
|
||||
? [
|
||||
{label: 'From', value: result.from},
|
||||
{label: 'To', value: result.to},
|
||||
{label: 'Subject', value: result.subject},
|
||||
{label: 'Date', value: result.date},
|
||||
{label: 'Message-ID', value: result.messageId},
|
||||
{label: 'Reply-To', value: result.replyTo},
|
||||
{label: 'Return-Path', value: result.returnPath},
|
||||
{label: 'X-Mailer', value: result.xMailer},
|
||||
].filter(f => f.value !== null)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Email Headers Analyzer | Parse & Debug Email Headers | Plunk"
|
||||
description="Free email headers analyzer. Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops, and authentication chain. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/email-headers"
|
||||
openGraph={{
|
||||
title: 'Email Headers Analyzer | Parse & Debug Email Headers | Plunk',
|
||||
description: 'Paste raw email headers and instantly see SPF/DKIM/DMARC results, routing hops, and authentication chain. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/email-headers',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Headers+Analyzer&tag=Tool', alt: 'Plunk Email Headers Analyzer', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-09 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
Email headers
|
||||
<br />
|
||||
analyzer
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops with timestamps, and the full authentication chain.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-3xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<FileText className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Raw header input
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAnalyze} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="rawHeaders" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Raw email headers <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="rawHeaders"
|
||||
value={raw}
|
||||
onChange={e => setRaw(e.target.value)}
|
||||
placeholder={'Paste raw headers here.\n\nIn Gmail: ⋮ menu → Show original\nIn Outlook: File → Properties → Internet headers\nIn Apple Mail: View → Message → All Headers'}
|
||||
required
|
||||
rows={10}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'w-full resize-y rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-xs text-neutral-700 placeholder:text-neutral-400 focus:border-neutral-400 focus:bg-white focus:outline-none'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Paste the full raw headers — no email body required. Everything is processed locally in your browser.</p>
|
||||
</div>
|
||||
<div className={'flex gap-3'}>
|
||||
<Button type="submit" className={'flex-1 gap-2'}>
|
||||
<FileText className={'h-4 w-4'} />
|
||||
Analyze headers
|
||||
</Button>
|
||||
{analysed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={'rounded-full border border-neutral-200 px-5 py-2.5 text-sm font-medium text-neutral-600 transition hover:border-neutral-400 hover:text-neutral-900'}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{analysed && result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{/* Authentication results */}
|
||||
{result.authResults.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Authentication results
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.authResults.map((auth, i) => (
|
||||
<li key={i} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
{auth.pass === true ? (
|
||||
<CheckCircle className={'mt-0.5 h-4 w-4 shrink-0 text-green-600'} />
|
||||
) : auth.pass === false ? (
|
||||
<XCircle className={'mt-0.5 h-4 w-4 shrink-0 text-red-500'} />
|
||||
) : (
|
||||
<AlertTriangle className={'mt-0.5 h-4 w-4 shrink-0 text-amber-500'} />
|
||||
)}
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<div className={'flex flex-wrap items-center gap-2'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold uppercase tracking-wide text-neutral-700'}>
|
||||
{auth.mechanism}
|
||||
</span>
|
||||
<AuthBadge result={auth.result} />
|
||||
{auth.detail && (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs text-neutral-400'}>
|
||||
{auth.detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.authResults.length === 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-100 bg-neutral-50 p-6'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-800'}>No Authentication-Results header found</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>This header is added by the receiving mail server. If it's missing, paste the full original headers, not just the visible ones.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DKIM signature info */}
|
||||
{(result.dkimDomain ?? result.dkimSelector) && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-4 text-lg font-bold text-neutral-900'}>
|
||||
DKIM signature
|
||||
</h3>
|
||||
<div className={'flex flex-wrap gap-4'}>
|
||||
{result.dkimDomain && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Signing domain</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimDomain}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.dkimSelector && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Selector</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimSelector}</p>
|
||||
</div>
|
||||
)}
|
||||
{result.dkimDomain && result.dkimSelector && (
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Public key DNS name</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>
|
||||
{result.dkimSelector}._domainkey.{result.dkimDomain}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Routing hops */}
|
||||
{result.hops.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Routing hops <span className={'text-base font-normal text-neutral-400'}>({result.hops.length} server{result.hops.length !== 1 ? 's' : ''})</span>
|
||||
</h3>
|
||||
<ol className={'space-y-3'}>
|
||||
{result.hops.map((hop, i) => (
|
||||
<li key={i} className={'flex gap-4'}>
|
||||
<div className={'flex flex-col items-center'}>
|
||||
<div className={'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-white'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[10px] font-bold text-neutral-500'}>
|
||||
{i + 1}
|
||||
</span>
|
||||
</div>
|
||||
{i < result.hops.length - 1 && <div className={'mt-1 h-full w-px bg-neutral-200'} />}
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1 pb-3'}>
|
||||
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex flex-wrap items-start justify-between gap-2'}>
|
||||
<div className={'min-w-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>From</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.from}</p>
|
||||
</div>
|
||||
<div className={'min-w-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>By</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.by}</p>
|
||||
</div>
|
||||
{hop.timestamp && (
|
||||
<div className={'shrink-0'}>
|
||||
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Time</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 text-xs text-neutral-600'}>{hop.timestamp}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message metadata */}
|
||||
{metaFields.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Message metadata
|
||||
</h3>
|
||||
<dl className={'space-y-3'}>
|
||||
{metaFields.map(field => (
|
||||
<div key={field.label} className={'flex flex-col gap-1 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3 sm:flex-row sm:gap-4'}>
|
||||
<dt style={{fontFamily: 'var(--font-mono)'}} className={'w-28 shrink-0 text-xs font-bold text-neutral-500'}>
|
||||
{field.label}
|
||||
</dt>
|
||||
<dd style={{fontFamily: 'var(--font-mono)'}} className={'min-w-0 break-all text-xs text-neutral-800'}>
|
||||
{field.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{analysed && !result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 rounded-[20px] border border-red-100 bg-red-50 p-8'}
|
||||
>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>Could not parse headers</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>Make sure you pasted raw email headers in standard format. Each header should be on its own line followed by a colon and value.</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'Email headers explained'}
|
||||
title={'What headers tell you.'}
|
||||
subtitle={'Every email carries a forensic trail. Here\'s what to look for when debugging deliverability.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
tag: 'Authentication',
|
||||
title: 'SPF, DKIM & DMARC',
|
||||
body: 'The Authentication-Results header is your first stop. It shows whether the sending server was authorised (SPF), the signature was valid (DKIM), and whether both align with the From domain (DMARC).',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
tag: 'Routing',
|
||||
title: 'Received chain',
|
||||
body: 'Received headers trace the path of your email from origin to inbox. Read them bottom-to-top. Large time gaps reveal where delays happened — useful for diagnosing why an email arrived late.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
tag: 'Identity',
|
||||
title: 'Return-Path & Reply-To',
|
||||
body: 'Return-Path is where bounces go — it must pass SPF. Reply-To overrides where replies are sent. A mismatch between From, Return-Path, and Reply-To can trigger spam filters or indicate phishing.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.tag}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>
|
||||
/ {item.tag}
|
||||
</span>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Never debug deliverability again.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk sets up authentication correctly from day one and gives you the analytics to catch deliverability issues before your users do.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/tools/dkim-checker"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
Check DKIM record
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-email-headers" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, ArrowUpRight, Code2, Search} from 'lucide-react';
|
||||
import {ArrowRight, ArrowUpRight, Code2, FileText, Key, Mail, Search, Shield, ShieldAlert, ShieldCheck} from 'lucide-react';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
@@ -43,21 +43,63 @@ const tools = [
|
||||
icon: Search,
|
||||
number: '02',
|
||||
},
|
||||
{
|
||||
name: 'Spam Checker',
|
||||
slug: 'spam-checker',
|
||||
description: 'Test your email subject line and content for spam trigger words and deliverability issues.',
|
||||
icon: ShieldAlert,
|
||||
number: '03',
|
||||
},
|
||||
{
|
||||
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 | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
title="Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk"
|
||||
description="Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
title: 'Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
'Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required.',
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -104,7 +146,7 @@ export default function ToolsIndex() {
|
||||
developer tools
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
|
||||
Check MX records, email headers, SPF, DMARC, and DKIM. Verify addresses, convert markdown to email-safe HTML, test for spam, and more. No sign-up required.
|
||||
</p>
|
||||
|
||||
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
|
||||
|
||||
@@ -46,15 +46,15 @@ export default function MarkdownToEmail() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
title="Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk"
|
||||
description="Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
title: 'Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
'Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Markdown+to+Email+HTML+Converter&tag=Tool', alt: 'Plunk Markdown to Email', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Mail, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface MxRecord {
|
||||
priority: number;
|
||||
exchange: string;
|
||||
}
|
||||
|
||||
interface MxLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
records: MxRecord[];
|
||||
issues: MxIssue[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface MxIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
function parseMxData(data: string): MxRecord {
|
||||
const parts = data.trim().split(/\s+/);
|
||||
const priority = parseInt(parts[0] ?? '0', 10);
|
||||
const exchange = (parts[1] ?? '').replace(/\.$/, '');
|
||||
return {priority, exchange};
|
||||
}
|
||||
|
||||
function analyzeMx(records: MxRecord[]): MxIssue[] {
|
||||
const issues: MxIssue[] = [];
|
||||
|
||||
if (records.length === 0) {
|
||||
issues.push({type: 'error', label: 'No MX records found', detail: 'Without MX records, mail servers cannot deliver email to this domain. Add at least one MX record pointing to your mail server.'});
|
||||
return issues;
|
||||
}
|
||||
|
||||
issues.push({type: 'pass', label: `${records.length} MX record${records.length > 1 ? 's' : ''} found`, detail: 'Mail servers can look up where to deliver email for this domain.'});
|
||||
|
||||
if (records.length === 1) {
|
||||
issues.push({type: 'warning', label: 'Single MX record — no redundancy', detail: 'If this mail server is unavailable, email delivery will fail. Consider adding a secondary MX record with a higher priority number as a fallback.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: 'Multiple MX records — redundancy configured', detail: 'If the primary server is unreachable, mail will fall back to lower-priority servers.'});
|
||||
}
|
||||
|
||||
const priorities = records.map(r => r.priority);
|
||||
const uniquePriorities = new Set(priorities);
|
||||
if (uniquePriorities.size < priorities.length) {
|
||||
issues.push({type: 'warning', label: 'Duplicate priority values', detail: 'Two or more MX records share the same priority. When priorities are equal, senders choose randomly between them. This may be intentional for load balancing, but verify it matches your setup.'});
|
||||
}
|
||||
|
||||
const hasMxPointingToIp = records.some(r => /^\d+\.\d+\.\d+\.\d+$/.test(r.exchange));
|
||||
if (hasMxPointingToIp) {
|
||||
issues.push({type: 'error', label: 'MX record points to an IP address', detail: 'MX records must point to a hostname, not an IP address. This is an RFC 5321 violation and many mail servers will reject mail. Change the MX value to a fully qualified domain name.'});
|
||||
}
|
||||
|
||||
const hasTrailingDot = records.some(r => r.exchange.endsWith('.'));
|
||||
if (!hasTrailingDot && records.some(r => r.exchange.includes('.'))) {
|
||||
issues.push({type: 'pass', label: 'MX hostnames look well-formed', detail: 'All exchange hostnames appear to be valid fully qualified domain names.'});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function lookupMx(domain: string): Promise<MxLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(clean)}&type=MX`,
|
||||
{headers: {Accept: 'application/dns-json'}},
|
||||
);
|
||||
if (!res.ok) return {domain: clean, found: false, records: [], issues: [], error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Status: number; Answer?: DnsAnswer[]};
|
||||
if (data.Status !== 0 || !Array.isArray(data.Answer) || data.Answer.length === 0) {
|
||||
const issues = analyzeMx([]);
|
||||
return {domain: clean, found: false, records: [], issues};
|
||||
}
|
||||
const records: MxRecord[] = data.Answer.map((a: DnsAnswer) => parseMxData(a.data)).sort((a, b) => a.priority - b.priority);
|
||||
const issues = analyzeMx(records);
|
||||
return {domain: clean, found: true, records, issues};
|
||||
} catch {
|
||||
return {domain: clean, found: false, records: [], issues: [], error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
function priorityLabel(priority: number): string {
|
||||
if (priority === 0) return 'Primary';
|
||||
if (priority <= 10) return 'Primary';
|
||||
if (priority <= 20) return 'Secondary';
|
||||
return 'Fallback';
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is an MX record?',
|
||||
answer: 'An MX (Mail Exchanger) record is a DNS record that specifies which mail servers are responsible for receiving email for a domain. When someone sends an email to you@yourdomain.com, the sending mail server looks up the MX records for yourdomain.com to find where to deliver the message.',
|
||||
},
|
||||
{
|
||||
question: 'What does the MX priority number mean?',
|
||||
answer: 'The priority number (also called preference) tells senders which mail server to try first. Lower numbers = higher priority. A server with priority 10 is tried before one with priority 20. If the primary server is unavailable, senders retry with the next highest priority server. Equal priority values means senders choose randomly — this is sometimes used for load balancing.',
|
||||
},
|
||||
{
|
||||
question: 'Do I need more than one MX record?',
|
||||
answer: 'It\'s strongly recommended. A single MX record means email will bounce or queue during any outage. Adding a secondary MX (typically your mail provider\'s backup server, or a service like Google MX backup) ensures email is accepted even if your primary server is temporarily down.',
|
||||
},
|
||||
{
|
||||
question: 'Why would email delivery fail even if MX records are correct?',
|
||||
answer: 'Several reasons: the mail server the MX points to may be down or unreachable on port 25; your IP or domain may be on a blacklist; SPF, DKIM, or DMARC might be misconfigured causing rejection; or the receiving server may be filtering based on content. MX records are just the first step — authentication records and reputation matter too.',
|
||||
},
|
||||
{
|
||||
question: 'Can I change my MX records without losing email?',
|
||||
answer: 'Yes, with care. Lower your TTL to 300 seconds a day before making changes so the change propagates quickly. Add the new MX records before removing the old ones. Wait for propagation (typically 5–30 minutes with a low TTL), confirm the new server is receiving, then remove the old records and restore your TTL.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MxCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<MxLookupResult | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const data = await lookupMx(domain);
|
||||
setResult(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="MX Record Checker | Free MX Lookup Tool | Plunk"
|
||||
description="Free MX record checker. Look up the mail exchange records for any domain, see server priority, and diagnose email delivery problems. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/mx-checker"
|
||||
openGraph={{
|
||||
title: 'MX Record Checker | Free MX Lookup Tool | Plunk',
|
||||
description: 'Look up MX records for any domain. Check mail server priority, redundancy, and diagnose delivery problems. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/mx-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=MX+Record+Checker&tag=Tool', alt: 'Plunk MX Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-08 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
MX record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up the mail exchange records for any domain. See mail server priority, check for redundancy, and diagnose email delivery problems.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Mail className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
MX record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="mxDomain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="mxDomain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Mail className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up MX records…' : 'Check MX records'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{result.error ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>DNS lookup failed</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>Could not query MX records for {result.domain}. Check the domain name and try again.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!result.found || result.records.length === 0 ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No MX records found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.domain} has no MX records. Email cannot be delivered to this domain. Add at least one MX record in your DNS provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
MX records for {result.domain}
|
||||
</h3>
|
||||
<div className={'space-y-3'}>
|
||||
{result.records.map((rec, i) => (
|
||||
<div key={i} className={'flex items-center gap-4 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<div className={'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-white'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold tabular-nums text-neutral-700'}>
|
||||
{rec.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className={'min-w-0 flex-1'}>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'block truncate text-sm font-medium text-neutral-900'}>
|
||||
{rec.exchange || '(empty)'}
|
||||
</span>
|
||||
<span className={'text-xs text-neutral-500'}>Priority {rec.priority} · {priorityLabel(rec.priority)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.issues.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
|
||||
Analysis
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'MX records explained'}
|
||||
title={'How email finds its destination.'}
|
||||
subtitle={'MX records are the address book of email routing — without them, no one can send you mail.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
step: '01',
|
||||
title: 'Sender looks up MX',
|
||||
body: 'When someone sends to you@domain.com, their mail server queries DNS for the MX records of domain.com. The priority numbers tell it which server to try first.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
title: 'Connection to port 25',
|
||||
body: 'The sending server opens an SMTP connection to port 25 on your mail server\'s hostname. If the connection fails, it tries the next MX in priority order.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
title: 'Authentication checks',
|
||||
body: 'Your mail server runs SPF, DKIM, and DMARC checks on the incoming message. Failures may cause the message to be filtered or rejected depending on your policy.',
|
||||
cls: 'border-neutral-300',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.step}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
|
||||
>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>
|
||||
Step {item.step}
|
||||
</span>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Email that reaches the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk handles SPF, DKIM, and DMARC setup and keeps your sending reputation clean so email lands in the inbox.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/tools/spf-checker"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
Check SPF record
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-mx-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, ShieldAlert, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
const SPAM_TRIGGER_WORDS = [
|
||||
'act now', 'act immediately', 'action required', 'apply now', 'apply online',
|
||||
'as seen on', 'avoid bankruptcy', 'be your own boss', 'being a member',
|
||||
'big bucks', 'bill 1618', 'billion dollars', 'bonus',
|
||||
'buy direct', 'buy now', 'buyer protection', 'buying judgments',
|
||||
'call free', 'call now', 'cancel at any time', 'can\'t live without',
|
||||
'cash bonus', 'cash prize', 'celebrate', 'cent on the dollar',
|
||||
'chance', 'cheap', 'check now', 'claim now', 'claim your', 'click below',
|
||||
'click here', 'click now', 'click to remove', 'compare rates',
|
||||
'compete for your business', 'congratulations', 'consolidate debt',
|
||||
'copy accurately', 'copy dvds', 'deal', 'dear friend',
|
||||
'earn per week', 'earn extra cash', 'earn extra income',
|
||||
'eliminate bad credit', 'eliminate debt', 'excl', 'exclusive deal',
|
||||
'exclusive offer', 'extra cash', 'extra income',
|
||||
'f r e e', 'fantastic deal', 'fast cash', 'financial freedom',
|
||||
'find out anything', 'for free', 'for just $', 'for only $',
|
||||
'free access', 'free bonus', 'free cell phone', 'free consultation',
|
||||
'free gift', 'free grant money', 'free info', 'free installation',
|
||||
'free investment', 'free leads', 'free membership', 'free money',
|
||||
'free offer', 'free preview', 'free prize', 'free quote', 'free sample',
|
||||
'free trial', 'freedom', 'full refund', 'get it now', 'get out of debt',
|
||||
'get paid', 'give it away', 'giving it away', 'great offer',
|
||||
'guaranteed', 'hidden assets', 'hidden charges',
|
||||
'home based business', 'hot deal', 'huge discount', 'incredible deal',
|
||||
'incredible offer', 'information you requested', 'instant access',
|
||||
'instant approval', 'insurance', 'it\'s effective', 'join millions',
|
||||
'joining for free', 'junk mail', 'last chance', 'limited time',
|
||||
'lose weight', 'lowest price', 'lowest rate', 'lucky winner',
|
||||
'luxury car', 'luxury gift', 'make $', 'make money', 'make money fast',
|
||||
'marketing solutions', 'mass email', 'meet singles', 'member',
|
||||
'million dollars', 'miracle', 'money back', 'money making',
|
||||
'monthly payment', 'mortgage', 'multi level marketing',
|
||||
'name brand', 'new customer', 'new domain', 'now only',
|
||||
'obligation free', 'off everything', 'offer expires',
|
||||
'once in a lifetime', 'one hundred percent free', 'one hundred percent satisfied',
|
||||
'open an account', 'opportunity', 'opt in', 'order now',
|
||||
'order status', 'order today', 'outstanding values',
|
||||
'pennies a day', 'per day', 'per month', 'per week',
|
||||
'potential earnings', 'price protection', 'prices reduced',
|
||||
'prize', 'profit', 'promise you', 'pure profit',
|
||||
'refinance', 'refund', 'register for free', 'remove',
|
||||
'reverses aging', 'risk free', 'satisfaction guaranteed',
|
||||
'save big money', 'save up to', 'selected', 'serious cash',
|
||||
'sign up free', 'special discount', 'special offer', 'special promotion',
|
||||
'subject to credit', 'subscribe for free', 'super deal', 'supplies are limited',
|
||||
'take action', 'tens of thousands', 'the best rates',
|
||||
'time limited', 'trial offer', 'ultimate',
|
||||
'unbeatable', 'unbelievable', 'urgent', 'valued customer',
|
||||
'very cheap', 'want to make', 'while supplies last', 'winner',
|
||||
'work at home', 'work from home', 'you are a winner', 'you have been selected',
|
||||
'you have been chosen', 'you won', 'you\'ve won', 'your income',
|
||||
];
|
||||
|
||||
interface SpamCheck {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface SpamResult {
|
||||
score: number;
|
||||
checks: SpamCheck[];
|
||||
triggeredWords: string[];
|
||||
}
|
||||
|
||||
function analyzeSpam(subject: string, body: string): SpamResult {
|
||||
const checks: SpamCheck[] = [];
|
||||
const triggeredWords: string[] = [];
|
||||
let deductions = 0;
|
||||
|
||||
const fullText = `${subject} ${body}`.toLowerCase();
|
||||
|
||||
// Check for spam trigger words
|
||||
const foundWords = SPAM_TRIGGER_WORDS.filter(word => fullText.includes(word));
|
||||
if (foundWords.length === 0) {
|
||||
checks.push({type: 'pass', label: 'No spam trigger words', detail: 'Your content does not contain common spam trigger phrases.'});
|
||||
} else if (foundWords.length <= 2) {
|
||||
checks.push({type: 'warning', label: `${foundWords.length} spam trigger word${foundWords.length > 1 ? 's' : ''} found`, detail: `Found: ${foundWords.slice(0, 5).join(', ')}. Consider rephrasing these.`});
|
||||
deductions += foundWords.length * 8;
|
||||
triggeredWords.push(...foundWords.slice(0, 5));
|
||||
} else {
|
||||
checks.push({type: 'error', label: `${foundWords.length} spam trigger words found`, detail: `Found: ${foundWords.slice(0, 6).join(', ')}${foundWords.length > 6 ? ` and ${foundWords.length - 6} more` : ''}. Rewrite to use natural language.`});
|
||||
deductions += Math.min(foundWords.length * 8, 45);
|
||||
triggeredWords.push(...foundWords.slice(0, 6));
|
||||
}
|
||||
|
||||
// Check subject line length
|
||||
if (subject.length === 0) {
|
||||
checks.push({type: 'error', label: 'Empty subject line', detail: 'A subject line is required. Missing subjects trigger spam filters.'});
|
||||
deductions += 20;
|
||||
} else if (subject.length < 10) {
|
||||
checks.push({type: 'warning', label: 'Subject line too short', detail: 'Very short subjects may appear suspicious. Aim for 30–50 characters.'});
|
||||
deductions += 5;
|
||||
} else if (subject.length > 70) {
|
||||
checks.push({type: 'warning', label: 'Subject line too long', detail: `${subject.length} characters. Subject lines over 70 characters are truncated and may reduce opens.`});
|
||||
deductions += 5;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'Good subject line length', detail: `${subject.length} characters — within the recommended 30–70 character range.`});
|
||||
}
|
||||
|
||||
// Check for excessive capitalization in subject
|
||||
const subjectWords = subject.split(' ').filter(w => w.length > 2);
|
||||
const capsWords = subjectWords.filter(w => w === w.toUpperCase() && /[A-Z]/.test(w));
|
||||
if (capsWords.length > 2) {
|
||||
checks.push({type: 'error', label: 'Excessive capitalization in subject', detail: `"${capsWords.join('", "')}" — all-caps words look like shouting and trigger spam filters.`});
|
||||
deductions += 15;
|
||||
} else if (capsWords.length > 0) {
|
||||
checks.push({type: 'warning', label: 'Capitalized words in subject', detail: `"${capsWords.join('", "')}" — use sentence case for a more professional appearance.`});
|
||||
deductions += 5;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'No excessive capitalization', detail: 'Subject line uses normal capitalization.'});
|
||||
}
|
||||
|
||||
// Check for excessive exclamation marks in subject
|
||||
const exclamationCount = (subject.match(/!/g) || []).length;
|
||||
if (exclamationCount > 1) {
|
||||
checks.push({type: 'error', label: 'Multiple exclamation marks in subject', detail: `${exclamationCount} exclamation marks found. Multiple "!" are a strong spam signal.`});
|
||||
deductions += 10;
|
||||
} else if (exclamationCount === 1) {
|
||||
checks.push({type: 'warning', label: 'Exclamation mark in subject', detail: 'A single exclamation mark is borderline. Prefer natural punctuation.'});
|
||||
deductions += 3;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'No excessive punctuation', detail: 'No exclamation marks in the subject line.'});
|
||||
}
|
||||
|
||||
// Check for $ in subject
|
||||
if (subject.includes('$')) {
|
||||
checks.push({type: 'warning', label: 'Dollar sign in subject', detail: 'The $ symbol in subject lines is a common spam trigger. Spell out "dollars" or reframe the message.'});
|
||||
deductions += 8;
|
||||
}
|
||||
|
||||
// Check for % in subject (common in discount spam)
|
||||
if (subject.includes('%')) {
|
||||
checks.push({type: 'warning', label: 'Percentage in subject', detail: 'Percentage symbols in subject lines are frequently associated with promotional spam.'});
|
||||
deductions += 5;
|
||||
}
|
||||
|
||||
// Check body length
|
||||
if (body.length > 0 && body.length < 50) {
|
||||
checks.push({type: 'warning', label: 'Very short email body', detail: 'Very short bodies with mostly links raise spam flags. Add more context and value.'});
|
||||
deductions += 5;
|
||||
} else if (body.length >= 50) {
|
||||
checks.push({type: 'pass', label: 'Email body length acceptable', detail: 'Body content length looks reasonable for a legitimate email.'});
|
||||
}
|
||||
|
||||
const score = Math.max(0, 100 - deductions);
|
||||
return {score, checks, triggeredWords};
|
||||
}
|
||||
|
||||
function ScoreBadge({score}: {score: number}) {
|
||||
const color = score >= 80 ? 'text-green-700 bg-green-50 border-green-200' : score >= 60 ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-red-700 bg-red-50 border-red-200';
|
||||
const label = score >= 80 ? 'Likely Clean' : score >= 60 ? 'Risky' : 'High Spam Risk';
|
||||
return (
|
||||
<div className={`inline-flex flex-col items-center rounded-2xl border-2 px-8 py-6 ${color}`}>
|
||||
<span className={'text-5xl font-extrabold'}>{score}</span>
|
||||
<span className={'mt-1 text-sm font-semibold uppercase tracking-widest'}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a spam checker?',
|
||||
answer:
|
||||
'A spam checker analyzes email subject lines and content for characteristics that spam filters use to block or flag emails. It looks for spam trigger words, excessive capitalization, suspicious punctuation, and other signals that inbox providers associate with spam. Running your email through a spam checker before sending helps improve deliverability.',
|
||||
},
|
||||
{
|
||||
question: 'What causes emails to go to spam?',
|
||||
answer:
|
||||
'Emails land in spam for many reasons: spam trigger words in the subject or body, poor sender reputation, missing SPF/DKIM/DMARC authentication, sending to purchased or unengaged lists with high bounce rates, excessive links or images, HTML-to-text ratio issues, or being reported as spam by previous recipients. Authentication issues and sender reputation are the most common causes.',
|
||||
},
|
||||
{
|
||||
question: 'How do I check if my email will be spam?',
|
||||
answer:
|
||||
'Use a spam checker tool like this one to analyze your subject line and content. Also check your email authentication setup—verify that SPF, DKIM, and DMARC are correctly configured for your domain. Send test emails to spam testing services, and monitor your deliverability metrics (bounce rate, spam complaint rate) over time.',
|
||||
},
|
||||
{
|
||||
question: 'What are spam trigger words?',
|
||||
answer:
|
||||
'Spam trigger words are phrases commonly associated with spam emails. They include words like "free", "guaranteed", "act now", "click here", "make money", "winner", "prize", "urgent", and many others. Spam filters assign point values to these words, and emails exceeding a threshold score are flagged or blocked. Avoiding these words and using natural language improves deliverability.',
|
||||
},
|
||||
{
|
||||
question: 'Is email authentication important for avoiding spam?',
|
||||
answer:
|
||||
'Yes—email authentication (SPF, DKIM, DMARC) is critical. Without proper authentication, receiving mail servers have no way to verify your email is legitimate, making it more likely to be treated as spam. Since 2024, Gmail and Yahoo require DMARC authentication for bulk senders. Properly authenticated emails with a clean sender reputation reach the inbox far more reliably than unauthenticated emails.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SpamCheckerPage() {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [bodyText, setBodyText] = useState('');
|
||||
const [result, setResult] = useState<SpamResult | null>(null);
|
||||
|
||||
const handleCheck = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setResult(analyzeSpam(subject, bodyText));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Email Spam Checker | Free Subject Line & Content Spam Test | Plunk"
|
||||
description="Free spam checker tool. Test your email subject line and content for spam trigger words, capitalization issues, and other factors that cause emails to land in spam."
|
||||
canonical="https://www.useplunk.com/tools/spam-checker"
|
||||
openGraph={{
|
||||
title: 'Email Spam Checker | Free Subject Line & Content Spam Test | Plunk',
|
||||
description: 'Free spam checker. Test your email subject line and content for factors that cause emails to land in spam folders.',
|
||||
url: 'https://www.useplunk.com/tools/spam-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Spam+Checker&tag=Tool', alt: 'Plunk Spam Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={
|
||||
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
|
||||
}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-03 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={
|
||||
'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||
}
|
||||
>
|
||||
Email spam
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Test your subject line and email content for spam trigger words, punctuation issues, and other
|
||||
factors that cause emails to land in spam folders.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CHECKER TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<ShieldAlert className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
Analyze email content
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'space-y-4 p-8'}>
|
||||
<div>
|
||||
<label htmlFor="subject" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Subject line <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="Your email subject line"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="body" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Email body text <span className={'text-neutral-400 text-xs font-normal'}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="body"
|
||||
value={bodyText}
|
||||
onChange={e => setBodyText(e.target.value)}
|
||||
placeholder="Paste your email body text here (plain text, no HTML needed)..."
|
||||
rows={6}
|
||||
className={'w-full rounded-lg border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 resize-none'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={'w-full gap-2'}>
|
||||
<ShieldAlert className={'h-4 w-4'} />
|
||||
Check for spam signals
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{/* Score */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<ScoreBadge score={result.score} />
|
||||
<p className={'max-w-sm text-sm text-neutral-600'}>
|
||||
{result.score >= 80
|
||||
? 'Your email looks clean. No major spam signals detected.'
|
||||
: result.score >= 60
|
||||
? 'Some spam signals detected. Review the issues below and consider rewriting flagged sections.'
|
||||
: 'High spam risk. Address the issues below before sending to protect deliverability.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Detailed analysis
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.checks.map((check, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{check.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : check.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{check.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{check.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{result.triggeredWords.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
|
||||
<h3 className={'mb-3 text-sm font-semibold text-red-900'}>Flagged phrases</h3>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
{result.triggeredWords.map((word, i) => (
|
||||
<span key={i} className={'rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-800'}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className={'mt-3 text-xs text-red-700'}>
|
||||
Replace these with natural, conversational language to improve deliverability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== WHY EMAILS GO TO SPAM ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'Spam factors'}
|
||||
title={'Why emails go to spam.'}
|
||||
subtitle={'Spam filters look at dozens of signals. Content is just one part of the picture.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Spam trigger words',
|
||||
body: 'Words like "free", "guaranteed", "click here", and "act now" score heavily with spam filters. Even one or two can tip the scales.',
|
||||
},
|
||||
{
|
||||
title: 'Missing authentication',
|
||||
body: 'Without SPF, DKIM, and DMARC configured, receiving servers cannot verify your email is legitimate. Authentication failures are the leading cause of spam classification.',
|
||||
},
|
||||
{
|
||||
title: 'Poor sender reputation',
|
||||
body: 'Email providers track bounce rates, spam complaints, and engagement over time. A low sender reputation score means even clean emails land in spam.',
|
||||
},
|
||||
{
|
||||
title: 'Excessive capitalization',
|
||||
body: 'ALL CAPS SUBJECT LINES and words in all caps look like shouting and are heavily penalized by spam filters. Use sentence case.',
|
||||
},
|
||||
{
|
||||
title: 'High bounce & complaint rates',
|
||||
body: 'If many recipients report your emails as spam or addresses bounce, providers penalize future emails from your domain.',
|
||||
},
|
||||
{
|
||||
title: 'Purchased email lists',
|
||||
body: 'Bought lists have unverified addresses, old data, and spam traps. Sending to them destroys your sender reputation quickly.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'
|
||||
}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Send emails that reach the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk automatically handles SPF, DKIM, and DMARC authentication — the most critical deliverability
|
||||
factor. Start free, no credit card required.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
|
||||
}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/email-deliverability"
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
|
||||
}
|
||||
>
|
||||
Deliverability guide
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-spam-checker" />
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, Shield, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
interface SpfMechanism {
|
||||
qualifier: string;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface SpfIssue {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface ParsedSpf {
|
||||
mechanisms: SpfMechanism[];
|
||||
allMechanism: string | null;
|
||||
issues: SpfIssue[];
|
||||
lookupCount: number;
|
||||
grade: 'pass' | 'warning' | 'fail';
|
||||
}
|
||||
|
||||
const DNS_LOOKUP_MECHS = new Set(['include', 'a', 'mx', 'ptr', 'exists', 'redirect']);
|
||||
|
||||
function parseSpfRecord(record: string): ParsedSpf {
|
||||
const parts = record.split(/\s+/);
|
||||
const mechanisms: SpfMechanism[] = [];
|
||||
let allMechanism: string | null = null;
|
||||
const issues: SpfIssue[] = [];
|
||||
let lookupCount = 0;
|
||||
|
||||
for (const part of parts.slice(1)) {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === '+all' || lower === '-all' || lower === '~all' || lower === '?all' || lower === 'all') {
|
||||
allMechanism = lower === 'all' ? '+all' : lower;
|
||||
continue;
|
||||
}
|
||||
|
||||
let qualifier = '+';
|
||||
let mech = part;
|
||||
const firstChar = part[0] ?? '';
|
||||
if (['+', '-', '~', '?'].includes(firstChar)) {
|
||||
qualifier = firstChar;
|
||||
mech = part.slice(1);
|
||||
}
|
||||
|
||||
const colonIdx = mech.indexOf(':');
|
||||
const slashIdx = mech.indexOf('/');
|
||||
const endIdx = colonIdx > -1 ? colonIdx : slashIdx > -1 ? slashIdx : mech.length;
|
||||
const type = mech.slice(0, endIdx).toLowerCase();
|
||||
const value = colonIdx > -1 ? mech.slice(colonIdx + 1) : '';
|
||||
|
||||
if (DNS_LOOKUP_MECHS.has(type)) lookupCount++;
|
||||
|
||||
mechanisms.push({qualifier, type, value});
|
||||
}
|
||||
|
||||
// Analyze
|
||||
if (!allMechanism) {
|
||||
issues.push({type: 'warning', label: 'No "all" mechanism', detail: 'SPF records should end with -all, ~all, or ?all to define default behaviour for unlisted senders.'});
|
||||
} else if (allMechanism === '+all') {
|
||||
issues.push({type: 'error', label: '+all allows any server to send', detail: '+all means any mail server in the world can send email claiming to be from your domain, completely defeating SPF protection.'});
|
||||
} else if (allMechanism === '?all') {
|
||||
issues.push({type: 'warning', label: '?all is too permissive', detail: '?all (neutral) gives no protection. Consider upgrading to ~all (softfail) or -all (hard fail).'});
|
||||
} else if (allMechanism === '~all') {
|
||||
issues.push({type: 'warning', label: '~all softfail is acceptable but not optimal', detail: 'Softfail marks unauthorised senders as suspicious but still delivers them. Prefer -all for maximum protection once your legitimate senders are configured.'});
|
||||
} else if (allMechanism === '-all') {
|
||||
issues.push({type: 'pass', label: '-all hard fail is the strongest policy', detail: 'Mail servers are instructed to reject email from any server not listed in your SPF record.'});
|
||||
}
|
||||
|
||||
if (lookupCount > 8) {
|
||||
issues.push({type: 'error', label: `DNS lookup limit exceeded (${lookupCount}/10)`, detail: `SPF is limited to 10 DNS lookups. Exceeding this causes a PermError, making SPF fail permanently. Consolidate includes or use IP ranges.`});
|
||||
} else if (lookupCount > 6) {
|
||||
issues.push({type: 'warning', label: `Approaching DNS lookup limit (${lookupCount}/10)`, detail: 'Adding more senders could push you over the 10 lookup limit. Monitor and consolidate where possible.'});
|
||||
} else {
|
||||
issues.push({type: 'pass', label: `DNS lookups within limit (${lookupCount}/10)`, detail: 'Your SPF record uses an acceptable number of DNS lookups.'});
|
||||
}
|
||||
|
||||
const ptrMechs = mechanisms.filter(m => m.type === 'ptr');
|
||||
if (ptrMechs.length > 0) {
|
||||
issues.push({type: 'warning', label: 'ptr mechanism is deprecated', detail: 'The ptr mechanism is slow and unreliable. RFC 7208 recommends avoiding it. Use ip4/ip6 or include instead.'});
|
||||
}
|
||||
|
||||
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
|
||||
|
||||
return {mechanisms, allMechanism, issues, lookupCount, grade};
|
||||
}
|
||||
|
||||
function qualifierLabel(q: string) {
|
||||
if (q === '+') return {label: 'PASS', cls: 'bg-green-50 text-green-700 border-green-200'};
|
||||
if (q === '-') return {label: 'FAIL', cls: 'bg-red-50 text-red-700 border-red-200'};
|
||||
if (q === '~') return {label: 'SOFTFAIL', cls: 'bg-amber-50 text-amber-700 border-amber-200'};
|
||||
return {label: 'NEUTRAL', cls: 'bg-neutral-100 text-neutral-600 border-neutral-300'};
|
||||
}
|
||||
|
||||
function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
|
||||
const map = {
|
||||
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'SPF is properly configured'},
|
||||
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'SPF has configuration issues'},
|
||||
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required', sub: 'SPF has critical errors'},
|
||||
};
|
||||
const {cls, label, sub} = map[grade];
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
|
||||
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
|
||||
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is an SPF record?',
|
||||
answer: 'An SPF (Sender Policy Framework) record is a DNS TXT record that lists the mail servers authorised to send email on behalf of your domain. Receiving mail servers check this record to verify that incoming email claiming to be from your domain was sent by an authorised server. Without SPF, anyone can spoof your domain in the From address.',
|
||||
},
|
||||
{
|
||||
question: 'What does -all vs ~all mean?',
|
||||
answer: '-all (hard fail) instructs receiving servers to reject any email not matching your SPF record. ~all (softfail) marks non-matching emails as suspicious but still delivers them. For production domains, -all is recommended once all your legitimate sending sources are added.',
|
||||
},
|
||||
{
|
||||
question: 'Why is there a 10 DNS lookup limit?',
|
||||
answer: 'RFC 7208 limits SPF to 10 DNS lookups to prevent denial-of-service attacks and excessive DNS load. Each include, a, mx, ptr, and exists mechanism counts as one lookup. Exceeding 10 lookups causes a PermError, which effectively makes SPF fail for your domain.',
|
||||
},
|
||||
{
|
||||
question: 'Can I have multiple SPF records?',
|
||||
answer: 'No. Having more than one SPF (v=spf1) TXT record on your domain causes a PermError and breaks SPF authentication. If you need to authorise multiple senders, combine everything into a single SPF record using multiple mechanisms.',
|
||||
},
|
||||
{
|
||||
question: 'Does SPF alone protect against spoofing?',
|
||||
answer: 'SPF alone is not enough. SPF only validates the envelope sender (the "Return-Path" address), not the visible "From" header. DMARC is required to connect SPF (and DKIM) validation to the From header and actually prevent spoofing of your visible sender address.',
|
||||
},
|
||||
];
|
||||
|
||||
interface DnsAnswer {
|
||||
data: string;
|
||||
}
|
||||
|
||||
interface SpfLookupResult {
|
||||
domain: string;
|
||||
found: boolean;
|
||||
multiple: boolean;
|
||||
records: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function cleanTxt(raw: string): string {
|
||||
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
|
||||
}
|
||||
|
||||
async function lookupSpf(domain: string): Promise<SpfLookupResult> {
|
||||
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
|
||||
try {
|
||||
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(clean)}&type=TXT`, {
|
||||
headers: {Accept: 'application/dns-json'},
|
||||
});
|
||||
if (!res.ok) return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
|
||||
const data = await res.json() as {Answer?: DnsAnswer[]};
|
||||
const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=spf1'));
|
||||
return {domain: clean, found: records.length > 0, multiple: records.length > 1, records};
|
||||
} catch {
|
||||
return {domain: clean, found: false, multiple: false, records: [], error: 'DNS lookup failed'};
|
||||
}
|
||||
}
|
||||
|
||||
export default function SpfCheckerPage() {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<SpfLookupResult | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedSpf | null>(null);
|
||||
|
||||
const handleCheck = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setParsed(null);
|
||||
|
||||
try {
|
||||
const data = await lookupSpf(domain);
|
||||
setResult(data);
|
||||
if (data.found && data.records[0]) {
|
||||
setParsed(parseSpfRecord(data.records[0]));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="SPF Record Checker | Free SPF Lookup & Validator | Plunk"
|
||||
description="Free SPF record checker. Look up and validate your domain's SPF record, check for misconfigurations, and get actionable advice to improve email deliverability."
|
||||
canonical="https://www.useplunk.com/tools/spf-checker"
|
||||
openGraph={{
|
||||
title: 'SPF Record Checker | Free SPF Lookup & Validator | Plunk',
|
||||
description: 'Free SPF record checker. Look up and validate your domain SPF record and get actionable deliverability advice.',
|
||||
url: 'https://www.useplunk.com/tools/spf-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+SPF+Record+Checker&tag=Tool', alt: 'Plunk SPF Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-04 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
|
||||
>
|
||||
SPF record
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Look up and validate your domain's SPF record. Get a full breakdown of your sending policy and
|
||||
catch misconfigurations before they hurt deliverability.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<Shield className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
SPF record lookup
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'p-8'}>
|
||||
<div className={'space-y-4'}>
|
||||
<div>
|
||||
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Domain name <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="domain"
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={e => setDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
|
||||
</div>
|
||||
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
|
||||
<Shield className={'h-4 w-4'} />
|
||||
{loading ? 'Looking up SPF record…' : 'Check SPF record'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{!result.found ? (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>No SPF record found</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
{result.error
|
||||
? 'DNS lookup failed. Please check the domain and try again.'
|
||||
: `No SPF record was found for ${result.domain}. Without SPF, anyone can send email impersonating your domain, and legitimate emails are more likely to land in spam.`}
|
||||
</p>
|
||||
<p className={'mt-3 text-sm font-medium text-red-800'}>
|
||||
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>{result.domain}</span>:
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900'}
|
||||
>
|
||||
v=spf1 include:your-email-provider.com -all
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{result.multiple && (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
|
||||
<div className={'flex items-start gap-3'}>
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
<div>
|
||||
<p className={'font-semibold text-red-900'}>Multiple SPF records detected</p>
|
||||
<p className={'mt-1 text-sm text-red-700'}>
|
||||
Having more than one SPF record causes a PermError, breaking SPF for your domain. Merge all mechanisms into a single v=spf1 record.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw record */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<p
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Raw record — {result.domain}
|
||||
</p>
|
||||
<code
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
|
||||
>
|
||||
{result.records[0]}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{parsed && (
|
||||
<>
|
||||
{/* Grade */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<GradeBadge grade={parsed.grade} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mechanisms */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Sending mechanisms
|
||||
</h3>
|
||||
<div className={'space-y-2'}>
|
||||
{parsed.mechanisms.map((m, i) => {
|
||||
const {label, cls} = qualifierLabel(m.qualifier);
|
||||
return (
|
||||
<div key={i} className={'flex items-center gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
|
||||
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-medium text-neutral-700'}>
|
||||
{m.type}
|
||||
</span>
|
||||
{m.value && (
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
|
||||
{m.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{parsed.allMechanism && (
|
||||
<div className={'flex items-center gap-3 rounded-lg border border-neutral-200 bg-neutral-100 px-4 py-3'}>
|
||||
<span
|
||||
className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${qualifierLabel(parsed.allMechanism[0] ?? '+').cls}`}
|
||||
>
|
||||
{qualifierLabel(parsed.allMechanism[0] ?? '+').label}
|
||||
</span>
|
||||
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-semibold text-neutral-700'}>
|
||||
all
|
||||
</span>
|
||||
<span className={'text-xs text-neutral-500'}>— default for all other senders</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Analysis & recommendations
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{parsed.issues.map((issue, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{issue.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : issue.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== EDUCATION ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'SPF explained'}
|
||||
title={'How SPF protects your domain.'}
|
||||
subtitle={'SPF is the first line of defence against email spoofing and phishing.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Authorise senders',
|
||||
body: 'SPF lets you publish a list of mail servers allowed to send on your behalf. Any server not on the list fails SPF validation.',
|
||||
},
|
||||
{
|
||||
title: 'Prevent spoofing',
|
||||
body: 'Without SPF, anyone can claim to send email from your domain. SPF makes it possible for receiving servers to detect and reject spoofed messages.',
|
||||
},
|
||||
{
|
||||
title: '10 lookup limit',
|
||||
body: 'SPF allows at most 10 DNS lookups per evaluation. Exceeding this causes a PermError, making SPF permanently fail. Monitor your lookup count carefully.',
|
||||
},
|
||||
{
|
||||
title: 'SPF is not enough alone',
|
||||
body: 'SPF validates the envelope sender, not the visible From header. You need DMARC to tie SPF (and DKIM) results to the From header and actually block spoofed email.',
|
||||
},
|
||||
{
|
||||
title: 'One record only',
|
||||
body: 'A domain must have exactly one SPF TXT record. Multiple v=spf1 records cause a PermError. Merge all sending sources into a single record.',
|
||||
},
|
||||
{
|
||||
title: 'Hard fail vs softfail',
|
||||
body: '-all (hard fail) instructs servers to reject non-matching mail. ~all (softfail) marks it as suspicious. Use -all in production once all senders are listed.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CTA ========== */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
SPF, DKIM, and DMARC — handled.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk guides you through domain authentication setup and monitors your sending reputation.
|
||||
Start free, no credit card required.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/what-is-spf"
|
||||
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
|
||||
>
|
||||
What is SPF?
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-spf-checker" />
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -58,14 +58,14 @@ export default function VerifyEmailPage() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
title="Email Validator | Free Email Address Verification Tool | Plunk"
|
||||
description="Free email validator tool. Check email addresses for validity, typos, disposable domains, and MX record configuration. Verify any email instantly."
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
title: 'Email Validator | Free Email Address Verification Tool | Plunk',
|
||||
description: 'Free email validator. Check email addresses for validity, typos, disposable domains, and MX records. Verify any email instantly.',
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Address+Validator&tag=Tool', alt: 'Plunk Email Validator Tool', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -61,7 +61,7 @@ export default function ActiveCampaignComparison() {
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=ActiveCampaign+Alternative%3A+Open-Source+%26+Affordable&tag=Comparison', alt: 'Plunk vs ActiveCampaign', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -170,6 +170,8 @@ export default function ActiveCampaignComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||
|
||||
<SwitchOffer competitorName="ActiveCampaign" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: '62,000 emails/month (from EC2)'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Pay-as-you-go (delivery only)'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: 'N/A (AWS-managed)'},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: true},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: false},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
|
||||
{feature: 'Contact Management', plunk: true, competitor: false},
|
||||
{feature: 'Dashboard & Analytics', plunk: true, competitor: false},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I use raw Amazon SES instead of Plunk?',
|
||||
answer:
|
||||
'Use raw SES if you have an existing email platform and only need a delivery layer, or if your engineering team wants full control over every part of the stack and has the capacity to build contact management, unsubscribe handling, bounce processing, and analytics themselves. Plunk is built on SES — when you self-host Plunk, you get the full platform at near-SES prices.',
|
||||
},
|
||||
{
|
||||
question: 'Is Plunk more expensive than Amazon SES?',
|
||||
answer:
|
||||
"SES charges $0.10 per 1,000 emails ($0.0001/email) for delivery alone. Plunk's managed service is $0.001/email — 10x more, but that includes contact management, campaigns, automation, segmentation, bounce/complaint handling, an admin dashboard, and unsubscribe management. If you self-host Plunk on your own infrastructure, you pay SES rates directly plus your hosting costs.",
|
||||
},
|
||||
{
|
||||
question: 'Does Plunk use Amazon SES under the hood?',
|
||||
answer:
|
||||
'Yes. Plunk uses AWS SES for email delivery. That means you get the same deliverability infrastructure as SES, plus the full platform layer on top. When you self-host Plunk, your SES account handles delivery directly — giving you SES pricing with Plunk functionality.',
|
||||
},
|
||||
{
|
||||
question: 'How hard is it to migrate from SES to Plunk?',
|
||||
answer:
|
||||
"If you're currently sending through SES directly, migrating to Plunk means switching your sending code to use Plunk's API instead of the SES SDK. Your existing SES domain verification and DKIM configuration can carry over. The benefit: you immediately gain contact management, bounce handling, unsubscribe lists, campaign tools, and a dashboard — without building any of that yourself.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function AmazonSesComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Amazon SES: Full Email Platform vs Raw Delivery | Plunk"
|
||||
description="Amazon SES only handles delivery — no contacts, no campaigns, no dashboard. Plunk is the full email platform built on SES infrastructure. Open-source, self-hostable, with marketing and automation included."
|
||||
canonical="https://www.useplunk.com/vs/amazon-ses"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Amazon SES: Full Email Platform vs Raw Delivery | Plunk',
|
||||
description:
|
||||
'SES is raw email delivery. Plunk is the full platform built on SES — contact management, campaigns, automation, and analytics. Open-source and self-hostable.',
|
||||
url: 'https://www.useplunk.com/vs/amazon-ses',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Plunk+vs+Amazon+SES&tag=Comparison', alt: 'Plunk vs Amazon SES', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Amazon SES
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
The full platform,
|
||||
<br />
|
||||
not just delivery.
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Amazon SES is raw email infrastructure — no contacts, no campaigns, no dashboard. Plunk is the complete email platform built on top of SES. Open-source, self-hostable, and available managed at $0.001 per email.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
The Real Cost Comparison
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>SES is cheap per email — but you still have to build everything else yourself</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Complete platform, managed</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>Everything included. Or self-host and pay SES rates directly.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001 / email</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Contact management, campaigns, automation</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Bounce & complaint handling built in</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Self-host for near-SES pricing</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Amazon SES</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Delivery infrastructure only</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Cheap delivery — but everything else is your problem to build.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>$0.0001 / email</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No contacts, no campaigns, no dashboard</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />You must handle bounces and complaints yourself</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Significant engineering cost to build the rest</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Provides</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything SES doesn't — ready to use, not to build</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Contact Management</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Store and manage your subscribers. Track events, manage unsubscribes, and build dynamic segments. SES has no concept of contacts.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send one-time broadcasts, schedule campaigns, track opens and clicks. SES delivers bytes — Plunk runs your entire email program.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Multi-step sequences triggered by events. Onboarding flows, drip campaigns, re-engagement. None of this exists in SES — you'd build it yourself.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute, and self-host. You get all the benefits of SES deliverability with full control over the platform layer.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run Plunk on your own infrastructure. Your SES account handles delivery — you get full-platform functionality at SES prices, nothing more.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Bounce & Complaint Handling</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Automatic processing of SES bounce and complaint notifications. Plunk keeps your sender reputation clean without any code on your end.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Amazon SES" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
|
||||
|
||||
<SwitchOffer competitorName="Amazon SES" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -61,7 +61,7 @@ export default function BentoComparison() {
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Bento+Alternative%3A+Developer-First+Email+Platform&tag=Comparison', alt: 'Plunk vs Bento', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -169,6 +169,8 @@ export default function BentoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||
|
||||
<SwitchOffer competitorName="Bento" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -56,14 +56,14 @@ export default function BrevoComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
description="Done with Brevo's contact-based pricing? Plunk is a developer-first, open-source alternative — pay per email not per contact, self-hostable, no contact limits."
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
"Done with Brevo's contact-based pricing? Plunk is open-source, self-hostable, and charges per email not per contact.",
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Brevo+Alternative%3A+Developer-First+%26+Open+Source&tag=Comparison', alt: 'Plunk vs Brevo', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,6 +193,8 @@ export default function BrevoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||
|
||||
<SwitchOffer competitorName="Brevo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: 'Up to 100 subscribers'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Per-subscriber monthly plans'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: false},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: false},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
|
||||
{feature: 'API Access', plunk: true, competitor: 'Limited'},
|
||||
{feature: 'Custom Domains', plunk: true, competitor: true},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Buttondown over Plunk?',
|
||||
answer:
|
||||
"Choose Buttondown if you're an individual writer or creator who only needs newsletter publishing with a simple, minimal interface. Buttondown is purpose-built for newsletters with a great writing experience. Choose Plunk if you need transactional emails alongside your newsletters, workflow automation, dynamic segmentation, or an open-source platform you can self-host.",
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Buttondown?',
|
||||
answer:
|
||||
"Plunk is pay-as-you-go at $0.001/email — you pay per email sent regardless of list size. Buttondown charges per subscriber: free up to 100, then $9/month for up to 1,000 subscribers, scaling up from there. If you have a large list but send infrequently, Plunk's model is significantly more cost-effective.",
|
||||
},
|
||||
{
|
||||
question: 'Can Plunk replace Buttondown for newsletter publishing?',
|
||||
answer:
|
||||
"Yes. Plunk's marketing campaigns handle newsletter-style broadcasts to your entire list or segments. You get scheduling, tracking (opens, clicks), and unsubscribe management. The main difference is Plunk is built for developers first — if you want a polished writing-focused UI without any code, Buttondown's editor may feel more natural.",
|
||||
},
|
||||
{
|
||||
question: "What does Plunk offer that Buttondown doesn't?",
|
||||
answer:
|
||||
'Plunk adds transactional emails (password resets, receipts, notifications), workflow automation (multi-step sequences triggered by events), dynamic audience segmentation, and a full API for programmatic control. Plunk is also open-source (AGPL-3.0) and self-hostable. Buttondown is focused purely on newsletter publishing with no transactional email support.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ButtondownComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Open-Source Buttondown Alternative: Newsletters + Transactional Emails | Plunk"
|
||||
description="Buttondown is newsletters-only. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with transactional emails, marketing campaigns, and workflow automation in one platform."
|
||||
canonical="https://www.useplunk.com/vs/buttondown"
|
||||
openGraph={{
|
||||
title: 'Open-Source Buttondown Alternative: Newsletters + Transactional Emails | Plunk',
|
||||
description:
|
||||
'Buttondown is newsletters-only. Plunk is open-source, pay-as-you-go, and handles transactional emails, newsletters, and automation in one self-hostable platform.',
|
||||
url: 'https://www.useplunk.com/vs/buttondown',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Buttondown+Alternative&tag=Comparison', alt: 'Plunk vs Buttondown', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Buttondown
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Buttondown
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Buttondown is newsletter-only. Plunk handles newsletters, transactional emails, and workflow automation in one open-source platform — at $0.001 per email, not per subscriber.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
Pay Per Email, Not Per Subscriber
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Your costs should scale with what you send, not the size of your list</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>$0.001 per email. A list of 10,000 subscribers costs nothing until you actually send.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Only pay when you actually send emails</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>No charge for subscriber count</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Transactional, marketing, and automation included</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Buttondown</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Per-subscriber monthly plans</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>$9/month for up to 1,000 subscribers, scaling as your list grows.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Per subscriber</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay based on list size, not sending activity</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Cost grows as your audience grows</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Newsletters only — no transactional emails</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond newsletter publishing</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Transactional Emails</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send password resets, receipts, notifications, and alerts through the same platform as your newsletters. Buttondown can't do this at all.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Welcome sequences, drip campaigns, re-engagement — Buttondown offers none of this.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Create audience segments that update automatically based on contact data and behavior. Go beyond static lists to target the right people at the right time.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Buttondown is a proprietary closed-source service.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Deploy on your own infrastructure with Docker. Full data ownership, GDPR compliance without relying on third-party processors.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer API</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Full REST API and SDKs for programmatic control. Trigger emails from your backend, track custom events, manage contacts — all without touching the dashboard.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Buttondown" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
|
||||
|
||||
<SwitchOffer competitorName="Buttondown" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -64,7 +64,7 @@ export default function ConvertkitComparison() {
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=ConvertKit+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs ConvertKit', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,6 +193,8 @@ export default function ConvertkitComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||
|
||||
<SwitchOffer competitorName="ConvertKit" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -52,14 +52,14 @@ export default function CustomerioComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
description="Frustrated with Customer.io's complexity and cost? Plunk is an open-source alternative with simple workflow automation, transparent pay-as-you-go pricing, and no CRM overhead."
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
"Frustrated with Customer.io's complexity and cost? Plunk is open-source with simple workflow automation and transparent pay-as-you-go pricing.",
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Customer.io+Alternative%3A+Open-Source+%26+Developer-Focused&tag=Comparison', alt: 'Plunk vs Customer.io', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -189,6 +189,8 @@ export default function CustomerioComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||
|
||||
<SwitchOffer competitorName="Customer.io" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -85,21 +85,39 @@ 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() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
title="Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk"
|
||||
description="Looking for a Resend, Mailgun, or SendGrid alternative? Plunk is open-source, self-hostable, and includes transactional emails, marketing campaigns, and workflow automation — at $0.001/email."
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
title: 'Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
'Open-source alternative to Resend, Mailgun, and SendGrid. Transactional + marketing emails in one self-hostable platform at $0.001/email.',
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Best+Email+Platform+Alternatives&tag=Comparison', alt: 'Plunk vs Competitors', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -182,10 +200,11 @@ export default function CompetitorsIndex() {
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'h-full'}
|
||||
>
|
||||
<Link
|
||||
href={`/vs/${competitor.slug}`}
|
||||
className={'group flex flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-6 sm:p-8 transition hover:border-neutral-900'}
|
||||
className={'group flex h-full flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-6 sm:p-8 transition hover:border-neutral-900'}
|
||||
>
|
||||
<div className={'flex items-start justify-between'}>
|
||||
<h3
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -61,7 +61,7 @@ export default function KlaviyoComparison() {
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Klaviyo+Alternative%3A+Affordable+E-commerce+Email&tag=Comparison', alt: 'Plunk vs Klaviyo', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -170,6 +170,8 @@ export default function KlaviyoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||
|
||||
<SwitchOffer competitorName="Klaviyo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -56,14 +56,14 @@ export default function LoopsComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||
description="Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing, no contact limits, and full code transparency."
|
||||
canonical="https://www.useplunk.com/vs/loops"
|
||||
openGraph={{
|
||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||
'Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing and no contact limits.',
|
||||
url: 'https://www.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Loops+Alternative%3A+Open+Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs Loops', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,6 +193,8 @@ export default function LoopsComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||
|
||||
<SwitchOffer competitorName="Loops" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -64,7 +64,7 @@ export default function MailchimpComparison() {
|
||||
description:
|
||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Mailchimp+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs Mailchimp', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -195,6 +195,8 @@ export default function MailchimpComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||
|
||||
<SwitchOffer competitorName="Mailchimp" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -61,7 +61,7 @@ export default function MailerliteComparison() {
|
||||
description:
|
||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=MailerLite+Alternative%3A+Open-Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs MailerLite', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -170,6 +170,8 @@ export default function MailerliteComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||
|
||||
<SwitchOffer competitorName="MailerLite" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -52,14 +52,14 @@ export default function MailgunComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||
description="Switching from Mailgun? Plunk is an open-source alternative with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers, no lock-in."
|
||||
canonical="https://www.useplunk.com/vs/mailgun"
|
||||
openGraph={{
|
||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||
'Switching from Mailgun? Plunk is open-source with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers.',
|
||||
url: 'https://www.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Mailgun+Alternative%3A+Open-Source+with+Marketing+%26+Automation&tag=Comparison', alt: 'Plunk vs Mailgun', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -189,6 +189,8 @@ export default function MailgunComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||
|
||||
<SwitchOffer competitorName="Mailgun" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const comparisonData: ComparisonRow[] = [
|
||||
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: '200 emails/day (6,000/month)'},
|
||||
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Monthly subscription plans'},
|
||||
{feature: 'Open Source', plunk: true, competitor: false},
|
||||
{feature: 'Self-Hostable', plunk: true, competitor: false},
|
||||
{feature: 'Transactional Emails', plunk: true, competitor: true},
|
||||
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
|
||||
{feature: 'Workflow Automation', plunk: true, competitor: false},
|
||||
{feature: 'Dynamic Segmentation', plunk: true, competitor: 'Basic'},
|
||||
{feature: 'Event Tracking', plunk: true, competitor: true},
|
||||
{feature: 'Custom Domains', plunk: true, competitor: true},
|
||||
];
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'When should I choose Mailjet over Plunk?',
|
||||
answer:
|
||||
'Choose Mailjet if you need inbox preview testing or built-in A/B testing for email campaigns. Mailjet has been around longer and has a broad feature set. Choose Plunk if you need workflow automation, want an open-source platform you can self-host, or prefer pay-as-you-go pricing without monthly subscription commitments.',
|
||||
},
|
||||
{
|
||||
question: 'What is the pricing difference between Plunk and Mailjet?',
|
||||
answer:
|
||||
"Plunk is pure pay-as-you-go at $0.001/email — you pay only for what you send. Mailjet uses monthly subscription tiers: their Essential plan starts around $17/month for 15,000 emails, scaling up from there. For variable sending volumes, Plunk is more cost-effective since you never pay for capacity you don't use.",
|
||||
},
|
||||
{
|
||||
question: 'Is migration from Mailjet to Plunk complex?',
|
||||
answer:
|
||||
"Migration requires updating your API integration since Plunk and Mailjet use different API structures. Core concepts (templates, webhooks, custom domains) map directly. Most teams complete the technical migration in a few hours. Contact lists and templates can be adapted with minimal effort.",
|
||||
},
|
||||
{
|
||||
question: "What does Plunk offer that Mailjet doesn't?",
|
||||
answer:
|
||||
'Plunk adds multi-step workflow automation (triggers, delays, conditions — perfect for onboarding and drip sequences) and is fully open-source under AGPL-3.0, meaning you can self-host on your own infrastructure. Mailjet offers neither workflow automation nor self-hosting. Plunk also uses pure pay-as-you-go pricing rather than tiered subscriptions.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MailjetComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Open-Source Mailjet Alternative: Pay-As-You-Go with Workflow Automation | Plunk"
|
||||
description="Mailjet uses monthly subscription plans and lacks workflow automation. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with transactional, marketing, and automation in one platform."
|
||||
canonical="https://www.useplunk.com/vs/mailjet"
|
||||
openGraph={{
|
||||
title: 'Open-Source Mailjet Alternative: Pay-As-You-Go with Workflow Automation | Plunk',
|
||||
description:
|
||||
'Mailjet uses monthly subscriptions and lacks automation. Plunk is pay-as-you-go, open-source, and self-hostable with transactional, marketing, and automation included.',
|
||||
url: 'https://www.useplunk.com/vs/mailjet',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Mailjet+Alternative&tag=Comparison', alt: 'Plunk vs Mailjet', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<main className={'text-neutral-800'}>
|
||||
|
||||
{/* Hero */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||
Plunk vs Mailjet
|
||||
</div>
|
||||
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
|
||||
Open-source alternative
|
||||
<br />
|
||||
for Mailjet
|
||||
</h1>
|
||||
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Mailjet is a solid email platform but locks you into monthly subscriptions and has no workflow automation. Plunk is open-source, self-hostable, pay-as-you-go, and includes automation — no second tool required.</p>
|
||||
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
|
||||
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
|
||||
View documentation
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing comparison */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||
The Pricing Model That Makes Sense
|
||||
</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Only pay for emails you actually send</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Scale up or down without commitment</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><div className={'h-4 w-4 flex-shrink-0 text-neutral-400'}>✓</div>Automation and marketing included</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
|
||||
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailjet</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Monthly subscription plans</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Tiered monthly plans based on email volume. Essential starts ~$17/month.</p>
|
||||
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
|
||||
<ul className={'mt-8 space-y-3'}>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Locked into monthly subscription tiers</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay for capacity whether you use it or not</li>
|
||||
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No workflow automation on any plan</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Key advantages */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
|
||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond what Mailjet offers</p>
|
||||
</motion.div>
|
||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Onboarding flows, drip campaigns, re-engagement — Mailjet has none of this.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Auto-updating audience segments based on contact data and behavior. Target campaigns with precision beyond Mailjet's basic list management.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Event Tracking</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Track any custom event from your product and use it to trigger workflows or build segments. Connect your app's behavior to your email platform.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Mailjet is a closed, proprietary platform.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your own infrastructure with Docker. Full data sovereignty, compliance-ready, and you only pay AWS SES fees when self-hosting.</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
|
||||
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
|
||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>Transactional, marketing, and automation under one roof. Pay-as-you-go for all of it — no separate tool for workflows.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison table */}
|
||||
<section className={'border-t border-neutral-200'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
|
||||
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
|
||||
</motion.div>
|
||||
<ComparisonTable competitorName="Mailjet" rows={comparisonData} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
|
||||
|
||||
<SwitchOffer competitorName="Mailjet" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
|
||||
Try Plunk free
|
||||
</motion.h2>
|
||||
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
|
||||
Get started free <ArrowRight className="h-4 w-4" />
|
||||
</motion.a>
|
||||
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
|
||||
View pricing
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -52,15 +52,15 @@ export default function PostmarkComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||
title="Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk"
|
||||
description="Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns, workflows, and open-source code."
|
||||
canonical="https://www.useplunk.com/vs/postmark"
|
||||
openGraph={{
|
||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||
title: 'Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk',
|
||||
description:
|
||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||
'Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns and open-source code.',
|
||||
url: 'https://www.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Postmark+Alternative%3A+Pay-As-You-Go+vs+Fixed+Pricing&tag=Comparison', alt: 'Plunk vs Postmark', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -190,6 +190,8 @@ export default function PostmarkComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||
|
||||
<SwitchOffer competitorName="Postmark" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -51,15 +51,15 @@ export default function ResendComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||
title="Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk"
|
||||
description="Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing campaigns and workflow automation included."
|
||||
canonical="https://www.useplunk.com/vs/resend"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||
title: 'Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||
'Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing and automation included.',
|
||||
url: 'https://www.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Resend+Alternative&tag=Comparison', alt: 'Plunk vs Resend', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -189,6 +189,8 @@ export default function ResendComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||
|
||||
<SwitchOffer competitorName="Resend" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -56,14 +56,14 @@ export default function SendGridComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||
description="Tired of SendGrid's pricing tiers and complexity? Plunk is an open-source alternative with pay-as-you-go pricing at $0.001/email, a modern API, and no lock-in."
|
||||
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||
openGraph={{
|
||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||
"Tired of SendGrid's pricing tiers and complexity? Plunk is open-source with pay-as-you-go pricing at $0.001/email and no lock-in.",
|
||||
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=SendGrid+Alternative%3A+Open+Source+%26+Pay-As-You-Go&tag=Comparison', alt: 'Plunk vs SendGrid', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -193,6 +193,8 @@ export default function SendGridComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||
|
||||
<SwitchOffer competitorName="SendGrid" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 125 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Binary file not shown.
@@ -241,7 +241,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
<div className="space-y-4">
|
||||
{upcomingActivities.map((activity, index) => (
|
||||
<div key={`${activity.id}-${index}`}>
|
||||
<ActivityItem activity={activity} isUpcoming={true} />
|
||||
<ActivityItem activity={activity} status="upcoming" />
|
||||
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -110,7 +110,7 @@ function isEmailActivity(type: string): boolean {
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity;
|
||||
isUpcoming?: boolean;
|
||||
status?: 'upcoming' | 'completed';
|
||||
}
|
||||
|
||||
interface ActivityConfig {
|
||||
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'event.triggered':
|
||||
return {
|
||||
icon: Zap,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
||||
description: undefined,
|
||||
badge: {
|
||||
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.sent':
|
||||
return {
|
||||
icon: Send,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-neutral-700',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.delivered':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.opened':
|
||||
return {
|
||||
icon: Eye,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-emerald-700',
|
||||
bgColor: 'bg-emerald-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
||||
description:
|
||||
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
||||
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.clicked':
|
||||
return {
|
||||
icon: MousePointerClick,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-sky-700',
|
||||
bgColor: 'bg-sky-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
||||
description:
|
||||
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
||||
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.started':
|
||||
return {
|
||||
icon: Workflow,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
||||
description: `Status: ${String(metadata.status || 'unknown')}`,
|
||||
badge: {
|
||||
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.completed':
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
||||
description: metadata.exitReason
|
||||
? `Exit: ${String(metadata.exitReason)}`
|
||||
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'campaign.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-sky-700',
|
||||
bgColor: 'bg-sky-50',
|
||||
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
||||
description: metadata.subject
|
||||
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
||||
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.email.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
||||
description: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
||||
@@ -342,11 +342,12 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
||||
export const ActivityItem = memo(function ActivityItem({activity, status = 'completed'}: ActivityItemProps) {
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const config = getActivityConfig(activity);
|
||||
const Icon = config.icon;
|
||||
const timestamp = new Date(activity.timestamp);
|
||||
const isUpcoming = status === 'upcoming';
|
||||
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user