Compare commits

...
55 Commits
Author SHA1 Message Date
Dries Augustyns cf6ad0b8e0 Merge pull request #241 from useplunk/release-please--branches--next--components--plunk 2025-12-29 21:23:49 +01:00
Dries Augustyns 782dc5754d chore: Add Plunk API key to entrypoint 2025-12-29 20:09:35 +01:00
Dries Augustyns cf88680a13 chore: Add Plunk API key to entrypoint 2025-12-29 20:01:18 +01:00
Dries Augustyns 6293259225 chore: Add free tools on marketing pages 2025-12-29 19:22:35 +01:00
github-actions[bot] a8678b8842 chore(next): release 0.3.0 2025-12-29 14:52:10 +00:00
Dries Augustyns 5993b842a0 fix: Add styling for visual editor emails in preview 2025-12-29 15:51:39 +01:00
Dries Augustyns 78cef418a1 chore: Copy over manifest files 2025-12-29 15:42:18 +01:00
Dries Augustyns d56ba0cf9b chore: Resolve linting warnings 2025-12-29 15:29:29 +01:00
Dries Augustyns 72dffe12e5 feat: Email preview in contact and activity feed 2025-12-29 15:26:53 +01:00
Dries Augustyns 2d353ef839 chore: Do not dockerignore scripts 2025-12-29 14:27:04 +01:00
Dries Augustyns f26ef53c8c chore: Improve startup speed of containers 2025-12-29 14:23:21 +01:00
Dries Augustyns 98b95b2ab2 chore: Add additional comparison pages 2025-12-29 11:53:08 +01:00
Dries Augustyns bc8611a662 feat: Add additional banner and information about security metrics 2025-12-28 14:00:15 +01:00
Dries Augustyns 2ddfceca36 docs: Improve docs with core-concept and guides 2025-12-28 10:46:46 +01:00
Dries Augustyns 862babb8f5 fix: Set auth type before disable check 2025-12-27 14:40:28 +01:00
Dries Augustyns 61cea95697 fix: Correctly reserve fields from being set on contact 2025-12-25 17:59:52 +01:00
Dries Augustyns 1fc1e23fce docs: Add locale overwrite to project documentation 2025-12-25 17:53:58 +01:00
Dries Augustyns 76155232a3 feat: Ability to overwrite locale on contact level with locale key on data 2025-12-25 17:37:25 +01:00
Dries Augustyns fa9e9de032 Merge pull request #249 from manjhss/feat/i18n-german-locale 2025-12-25 16:59:43 +01:00
Surendra Manjhi a6bd2e7dba feat(i18n): add German translations and update supported languages 2025-12-25 16:20:55 +05:30
Dries Augustyns e58dc1d056 Merge pull request #248 from manish-raana/feat/add-hindi-translations 2025-12-25 08:43:29 +01:00
Manish Rana ddc14ae853 feat(i18n): add Hindi translations for contact-facing pages
- Add hi.json locale file with Hindi translations
- Add Hindi language to SUPPORTED_LANGUAGES
- Import Hindi translations in i18n index

Closes #246
2025-12-24 23:59:35 +05:30
Dries Augustyns 0d54b1a631 docs: Add plus address check to /v1/verify 2025-12-24 12:18:53 +01:00
Dries Augustyns afc405ec02 feat: Add plus address check to /v1/verify 2025-12-24 12:14:39 +01:00
Dries Augustyns 6a9f6aa65a feat: Add email verification endpoint at /v1/verify 2025-12-24 09:54:50 +01:00
Dries Augustyns 8f725c7c84 fix: Properly tag events in SegmentFilterBuilder.tsx 2025-12-23 10:52:03 +01:00
Dries Augustyns 97ab0a2c2c fix: Date filtering not working properly for custom contact data 2025-12-22 21:39:15 +01:00
Dries Augustyns ddd58c20aa Merge pull request #247 from benoiteveillard/feat/i18n-french-locale 2025-12-22 10:33:29 +01:00
Benoît Eveillard 489c465599 i18n: add French (fr) locale 2025-12-22 09:46:28 +01:00
Dries Augustyns 451dd0327f fix: Import no longer case-sensitive about email column 2025-12-21 16:43:00 +01:00
Dries Augustyns e1f826357d feat: Add project-scoped language for unsubscribe footer and contact-facing pages 2025-12-21 13:09:21 +01:00
Dries Augustyns 8a136dde55 feat: Allow to pick currency when starting subscription 2025-12-21 11:53:32 +01:00
Dries Augustyns cc9b0f8a73 chore: Remove incorrect information from SEO schemas 2025-12-21 11:00:47 +01:00
Dries Augustyns 3cada63f15 chore: Correct linting errors in sitemap 2025-12-21 09:12:29 +01:00
Dries Augustyns 7fdcb22515 chore: Introduce sitemap for wiki 2025-12-21 09:07:14 +01:00
Dries Augustyns d7b5d3f60e fix: Pass through email verification if auth type is apiKey 2025-12-20 21:40:26 +01:00
Dries Augustyns 24f82b1be5 chore: Improve dropdown on mobile Navbar.tsx 2025-12-20 21:05:15 +01:00
Dries Augustyns 708dc81b88 chore: Make guide pages responsive on small screens 2025-12-20 21:03:17 +01:00
Dries Augustyns 35f5275d88 fix: Redirect verification link to dashboard instead of landing 2025-12-20 20:41:26 +01:00
Dries Augustyns 9567144390 fix: Do not check verification if platform emails are not enabled 2025-12-20 20:12:43 +01:00
Dries Augustyns 1a5607f278 feat: Add email verification and password reset 2025-12-20 20:06:25 +01:00
Dries Augustyns 7e25c148cc chore: Add index pages for /vs 2025-12-20 11:47:12 +01:00
Dries Augustyns 122713c39a chore: Resolve sitemap config by turning it into .cjs 2025-12-20 11:27:08 +01:00
Dries Augustyns 070fd812b2 chore: Ignore linting error due to useLayoutEffect 2025-12-20 11:18:46 +01:00
Dries Augustyns 4156cd436d chore: Add guides and comparison pages to landing site 2025-12-20 11:05:45 +01:00
Dries Augustyns 996852a506 chore: Additional pages 2025-12-19 19:20:30 +01:00
Dries Augustyns 2fb588357a chore: Add free tier to landing page 2025-12-19 17:25:28 +01:00
Dries Augustyns c3a276cde3 chore: Add additional links and shields 2025-12-19 14:20:10 +01:00
Dries Augustyns 661bf2a9e7 style: Remove shadow from input element for consistency 2025-12-19 13:40:52 +01:00
Dries Augustyns 007a908e83 fix: Persistence of subscription state for existing contacts 2025-12-18 19:43:59 +01:00
Dries Augustyns 23b4ec992d Merge pull request #245 from hemanth5544/next 2025-12-18 16:13:00 +01:00
Dries Augustyns 726f66762b feat: Add bulk actions to contact overview 2025-12-18 16:12:42 +01:00
Hemanth Rachapalli 3b77ca5570 style: Remove active line background color in HtmlEditor 2025-12-18 18:44:16 +05:30
Dries Augustyns 8c0304273c fix: Variable substitution in transactional emails 2025-12-17 20:00:27 +01:00
Dries Augustyns dc9b88dedb docs: Update openapi.json to match actual API outputs 2025-12-17 08:01:17 +01:00
185 changed files with 20568 additions and 1797 deletions
+1
View File
@@ -57,6 +57,7 @@ vitest.config.ts
Dockerfile*
docker
!docker/nginx
!docker/*.sh
.dockerignore
# Misc
+14
View File
@@ -18,6 +18,20 @@ DASHBOARD_DOMAIN=app.example.com
LANDING_DOMAIN=www.example.com
WIKI_DOMAIN=docs.example.com
# For local development: URIs for running services locally
# These are used by the applications at runtime
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# Use placeholder URLs that will be replaced at Docker container runtime
NEXT_PUBLIC_API_URI=https://api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://docs.useplunk.com
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
# This affects how application URIs are auto-generated from domain names
USE_HTTPS=false
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.2.0"
".": "0.3.0"
}
+40
View File
@@ -1,5 +1,45 @@
# Changelog
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
### Features
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
### Bug Fixes
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
### Documentation
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
+29
View File
@@ -123,6 +123,10 @@ COPY --from=deps /app/yarn.lock ./
# Copy root config files needed for Turbo
COPY turbo.json ./
# Copy manifest generation script
COPY docker/generate-url-manifest.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
# Step 1: Copy and build shared packages (these change less frequently)
# Shared packages are dependencies for apps, so build them first
COPY packages ./packages
@@ -171,6 +175,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=wiki
# Generate sitemap for wiki
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
# Generate URL replacement manifest for wiki (build-time optimization)
RUN generate-url-manifest.sh wiki /app/apps/wiki
# Step 4: Copy and build Web dashboard
COPY apps/web ./apps/web
@@ -184,6 +192,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=web
# Generate sitemap for web
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
# Generate URL replacement manifest for web (build-time optimization)
RUN generate-url-manifest.sh web /app/apps/web
# Step 5: Copy and build Landing page
COPY apps/landing ./apps/landing
@@ -197,6 +209,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=landing
# Generate sitemap for landing
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
# Generate URL replacement manifest for landing (build-time optimization)
RUN generate-url-manifest.sh landing /app/apps/landing
# Copy any remaining root files (if needed)
COPY . .
@@ -284,17 +300,26 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
# Landing app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
# Wiki app - standalone build with static assets and OpenAPI spec
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
# Copy full .next directories for the entrypoint script (URL replacement via find command)
# These are much smaller than node_modules and needed for runtime URL replacement
@@ -311,6 +336,10 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./app
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
RUN chmod +x /app/docker/nginx/setup-nginx.sh
# Copy optimized URL replacement script
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
RUN chmod +x /app/docker/replace-urls-optimized.sh
# Copy entrypoint script
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
+9 -5
View File
@@ -7,10 +7,14 @@
</p>
<p align="center">
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
<img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml"/>
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
<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>
</p>
## Introduction
@@ -47,7 +51,7 @@ the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
<a href="https://github.com/useplunk/plunk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
</a>
## License
+2
View File
@@ -22,6 +22,7 @@
"@plunk/shared": "*",
"@plunk/types": "*",
"@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.63.2",
@@ -34,6 +35,7 @@
"helmet": "^8.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"signale": "^1.4.0",
@@ -733,4 +733,304 @@ describe('Actions API Integration Tests', () => {
});
});
});
// ========================================
// SUBSCRIPTION STATUS PRESERVATION
// ========================================
describe('Subscription Status Preservation', () => {
describe('/v1/send endpoint', () => {
it('should NOT change subscription status when sending to subscribed contact without subscribed field', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Send transactional email without specifying subscribed field
await EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
// Verify subscription status unchanged
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(true);
});
it('should NOT change subscription status when sending to unsubscribed contact without subscribed field', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Send transactional email without specifying subscribed field
await EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
// Verify subscription status unchanged (should still be false)
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(false);
});
it('should allow explicit subscription when subscribed=true is provided', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// This test would need to be implemented at the controller level
// since EmailService.sendTransactionalEmail doesn't accept subscribed parameter
// For now, verify the schema allows it
const result = ActionSchemas.send.safeParse({
to: contact.email,
subject: 'Test',
body: 'Test',
from: '[email protected]',
subscribed: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(true);
}
});
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
// Verify the schema allows explicit false
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: '[email protected]',
subscribed: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(false);
}
});
it('should default to undefined when subscribed field is omitted', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
expect(result.success).toBe(true);
if (result.success) {
// Should be undefined, not false
expect(result.data.subscribed).toBeUndefined();
}
});
});
describe('/v1/track endpoint', () => {
it('should NOT change subscription status when tracking event for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Track event without specifying subscribed field
// This would be done via ContactService.upsert in the track endpoint
const {ContactService} = await import('../../services/ContactService.js');
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
// Verify subscription status unchanged
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when tracking event', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Track event without specifying subscribed field
const {ContactService} = await import('../../services/ContactService.js');
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
// Verify subscription status unchanged (should still be false, NOT re-subscribed)
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(false);
});
it('should create new contacts as subscribed when subscribed is undefined', async () => {
const newEmail = '[email protected]';
// Track event for new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should allow explicit subscription when subscribed=true is provided', async () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
subscribed: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(true);
}
});
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
subscribed: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(false);
}
});
it('should default to undefined when subscribed field is omitted', () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
});
expect(result.success).toBe(true);
if (result.success) {
// Should be undefined, not true
expect(result.data.subscribed).toBeUndefined();
}
});
});
describe('ContactService.upsert behavior', () => {
it('should preserve subscription status when undefined is passed for existing contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Update with undefined subscribed
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should preserve unsubscribed status when undefined is passed', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Update with undefined subscribed
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should allow explicit subscription change to true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Explicitly subscribe
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should allow explicit subscription change to false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Explicitly unsubscribe
await ContactService.upsert(projectId, contact.email, {}, false);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
});
});
+6
View File
@@ -103,3 +103,9 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
+71 -4
View File
@@ -8,13 +8,15 @@ import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {EventService} from '../services/EventService.js';
import {NotFound, ValidationError} from '../exceptions/index.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {DASHBOARD_URI} from '../app/constants.js';
/**
* Public API Actions Controller
* Handles track event and transactional email endpoints
* Handles track event, transactional email, and email verification endpoints
*/
@Controller('v1')
export class Actions {
@@ -25,7 +27,7 @@ export class Actions {
* Request body:
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional, default: true) - Contact subscription status
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
@@ -110,7 +112,7 @@ export class Actions {
* - Array: ["[email protected]", {name: "Jane", email: "[email protected]"}]
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional, default: false) - Contact subscription status
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
@@ -271,12 +273,23 @@ export class Actions {
// Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
// Add system variables (email, unsubscribe URLs, etc.) to merged data
// These are always available for template rendering
const dataWithSystemVars = {
...mergedData,
email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
// Render template with contact data
// Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!;
let renderedBody = emailBody!;
for (const [key, value] of Object.entries(mergedData)) {
for (const [key, value] of Object.entries(dataWithSystemVars)) {
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
@@ -329,4 +342,58 @@ export class Actions {
},
});
}
/**
* POST /v1/verify
* Verify an email address
*
* Request body:
* - email: string (required) - Email address to verify
*
* Response:
* - success: boolean
* - data: object with verification results
* - email: string - Email address that was verified
* - valid: boolean - Whether the email appears to be valid
* - isDisposable: boolean - Whether the email is from a disposable domain
* - hasMxRecords: boolean - Whether the domain has MX records configured
* - suggestedEmail?: string - Suggested correction if typo detected
* - reasons: string[] - Array of reasons describing the verification results
*
* Example:
* {
* email: "[email protected]"
* }
*
* Response:
* {
* success: true,
* data: {
* email: "[email protected]",
* valid: false,
* isDisposable: false,
* hasMxRecords: false,
* suggestedEmail: "[email protected]",
* reasons: [
* "Possible typo detected, did you mean [email protected]?",
* "Domain does not exist or has no MX records"
* ]
* }
* }
*/
@Post('verify')
@Middleware([requireSecretKey])
@CatchAsync
public async verify(req: Request, res: Response, _next: NextFunction) {
// Zod validation - errors automatically handled by global error handler
const {email} = ActionSchemas.verify.parse(req.body);
// Verify the email address
const verificationResult = await EmailVerificationService.verifyEmail(email);
return res.status(200).json({
success: true,
data: verificationResult,
});
}
}
+6 -6
View File
@@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -21,7 +21,7 @@ export class Activity {
* - endDate: ISO date string
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -62,7 +62,7 @@ export class Activity {
* - endDate: ISO date string (defaults to now)
*/
@Get('stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -82,7 +82,7 @@ export class Activity {
* - minutes: number (default 5)
*/
@Get('recent-count')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -98,7 +98,7 @@ export class Activity {
* Get available activity types (for UI filters)
*/
@Get('types')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType);
@@ -114,7 +114,7 @@ export class Activity {
* - daysAhead: number (default 30, max 90)
*/
@Get('upcoming')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+5 -5
View File
@@ -2,7 +2,7 @@ import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,7 +19,7 @@ export class Analytics {
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/
@Get('timeseries')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -41,7 +41,7 @@ export class Analytics {
* - endDate: ISO date string (defaults to now)
*/
@Get('top-campaigns')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -65,7 +65,7 @@ export class Analytics {
* Returns aggregate stats: total campaigns, active, completed, average rates
*/
@Get('campaign-stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -89,7 +89,7 @@ export class Analytics {
* Returns events sorted by frequency with trend data
*/
@Get('top-events')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+194 -2
View File
@@ -1,11 +1,25 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';
import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js';
import {
DASHBOARD_URI,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {jwt} from '../middleware/auth.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
@@ -64,6 +78,8 @@ export class Auth {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
// Auto-verify email if platform emails are disabled
emailVerified: !PLUNK_ENABLED,
},
});
@@ -72,6 +88,27 @@ export class Auth {
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
// Send email verification if platform emails are enabled
if (PLUNK_ENABLED) {
const verificationToken = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(verificationToken),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
);
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
await sendPlatformEmail(
created_user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {
email: created_user.email,
verificationUrl,
landingUrl: LANDING_URI,
}),
);
}
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
@@ -97,4 +134,159 @@ export class Auth {
},
});
}
@Post('verify-email')
@CatchAsync
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
// Look up token in Redis
const data = await redis.get(Keys.User.emailVerificationToken(token));
if (!data) {
throw new BadRequest('Invalid or expired verification token');
}
const {userId} = JSON.parse(data);
// Update user
await prisma.user.update({
where: {id: userId},
data: {emailVerified: true},
});
// Delete token (single use) and invalidate cache
await redis.del(Keys.User.emailVerificationToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Email verified successfully'}});
}
@Post('request-verification')
@CatchAsync
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
const userId = parseJwt(req);
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
if (user.emailVerified) {
return res.json({success: true, data: {message: 'Email already verified'}});
}
// Check rate limit
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
throw new RateLimitError('Too many verification emails sent. Please try again later.');
}
// Generate token
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
);
// Send email
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
await sendPlatformEmail(
user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
return res.json({success: true, data: {message: 'Verification email sent'}});
}
@Post('request-password-reset')
@CatchAsync
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
// Check rate limit
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
// Still return success to prevent enumeration
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
// Look up user
const user = await UserService.email(email);
// Only send email if user exists and is PASSWORD type
if (user && user.type === 'PASSWORD') {
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.passwordResetToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
);
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
await sendPlatformEmail(
user.email,
'Reset your password',
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
}
// Always return success (prevent enumeration)
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
@Post('reset-password')
@CatchAsync
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
// Look up token
const data = await redis.get(Keys.User.passwordResetToken(token));
if (!data) {
throw new BadRequest('Invalid or expired reset token');
}
const {userId} = JSON.parse(data);
// Get user and verify type
const user = await prisma.user.findUnique({where: {id: userId}});
if (!user || user.type !== 'PASSWORD') {
throw new BadRequest('Invalid reset token');
}
// Hash new password and update
const hashedPassword = await AuthService.generateHash(newPassword);
await prisma.user.update({
where: {id: userId},
data: {password: hashedPassword},
});
// Delete token and invalidate cache
await redis.del(Keys.User.passwordResetToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Password reset successfully'}});
}
}
+11 -11
View File
@@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -17,7 +17,7 @@ export class Campaigns {
* POST /campaigns
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -60,7 +60,7 @@ export class Campaigns {
* GET /campaigns
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -93,7 +93,7 @@ export class Campaigns {
* GET /campaigns/:id
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -112,7 +112,7 @@ export class Campaigns {
* PUT /campaigns/:id
*/
@Put(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -158,7 +158,7 @@ export class Campaigns {
* DELETE /campaigns/:id
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -177,7 +177,7 @@ export class Campaigns {
* POST /campaigns/:id/duplicate
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -197,7 +197,7 @@ export class Campaigns {
* POST /campaigns/:id/send
*/
@Post(':id/send')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -228,7 +228,7 @@ export class Campaigns {
* POST /campaigns/:id/cancel
*/
@Post(':id/cancel')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -248,7 +248,7 @@ export class Campaigns {
* GET /campaigns/:id/stats
*/
@Get(':id/stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -267,7 +267,7 @@ export class Campaigns {
* POST /campaigns/:id/test
*/
@Post(':id/test')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+158 -11
View File
@@ -4,7 +4,7 @@ import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -32,7 +32,7 @@ export class Contacts {
* List all contacts for the authenticated project with cursor-based pagination
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -51,7 +51,7 @@ export class Contacts {
* Returns field names with inferred types (string, number, boolean, date)
*/
@Get('fields')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -77,7 +77,7 @@ export class Contacts {
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/
@Get('fields/:field/values')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -110,7 +110,7 @@ export class Contacts {
* Get a specific contact by ID
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -130,7 +130,7 @@ export class Contacts {
* Create or update a contact (upsert)
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -160,7 +160,7 @@ export class Contacts {
* Update a contact
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -181,7 +181,7 @@ export class Contacts {
* Delete a contact
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -211,10 +211,24 @@ export class Contacts {
const contact = await ContactService.getById(contactId);
// Fetch project to get language preference
const project = await ContactService.getProjectByContactId(contactId);
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
language: contactLocale || project?.language || 'en',
});
}
@@ -301,7 +315,7 @@ export class Contacts {
* Get import job status
*/
@Get('import/:jobId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
@@ -332,7 +346,7 @@ export class Contacts {
* Returns information about where the field is used and whether it can be safely deleted
*/
@Get('fields/:field/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -359,7 +373,7 @@ export class Contacts {
* Only works if the field is not used in any segments or campaigns
*/
@Delete('fields/:field')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -379,4 +393,137 @@ export class Contacts {
});
}
}
/**
* POST /contacts/bulk-subscribe
* Queue bulk subscribe operation
*/
@Post('bulk-subscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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',
});
}
}
/**
* POST /contacts/bulk-unsubscribe
* Queue bulk unsubscribe operation
*/
@Post('bulk-unsubscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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',
});
}
}
/**
* POST /contacts/bulk-delete
* Queue bulk delete operation
*/
@Post('bulk-delete')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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',
});
}
}
/**
* GET /contacts/bulk/:jobId
* Get bulk action job status
*/
@Get('bulk/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getBulkActionJobStatus(jobId);
if (!status) {
return res.status(404).json({error: 'Bulk action job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get bulk action status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
});
}
}
}
+5 -5
View File
@@ -5,7 +5,7 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js';
@@ -17,7 +17,7 @@ export class Domains {
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -44,7 +44,7 @@ export class Domains {
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -104,7 +104,7 @@ export class Domains {
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -141,7 +141,7 @@ export class Domains {
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+8 -8
View File
@@ -3,7 +3,7 @@ import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,7 +14,7 @@ export class Events {
* Track a custom event (can trigger workflows)
*/
@Post('track')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -34,7 +34,7 @@ export class Events {
* List events for the project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -51,7 +51,7 @@ export class Events {
* Get event statistics
*/
@Get('stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -68,7 +68,7 @@ export class Events {
* Get events for a specific contact
*/
@Get('contact/:contactId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -89,7 +89,7 @@ export class Events {
* Get unique event names for the project
*/
@Get('names')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -105,7 +105,7 @@ export class Events {
* Returns information about where the event is used and whether it can be safely deleted
*/
@Get(':eventName/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -132,7 +132,7 @@ export class Events {
* Only works if the event is not used in any segments or workflows
*/
@Delete(':eventName')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+1
View File
@@ -85,6 +85,7 @@ export class Github {
data: {
email,
type: 'GITHUB_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
+1
View File
@@ -75,6 +75,7 @@ export class Google {
data: {
email,
type: 'GOOGLE_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
+7 -7
View File
@@ -5,7 +5,7 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -16,7 +16,7 @@ export class Projects {
* GET /projects/:id/setup-state
*/
@Get(':id/setup-state')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -89,7 +89,7 @@ export class Projects {
* GET /projects/:id/security
*/
@Get(':id/security')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -121,7 +121,7 @@ export class Projects {
* GET /projects/:id/members
*/
@Get(':id/members')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -170,7 +170,7 @@ export class Projects {
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/
@Post(':id/members')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -253,7 +253,7 @@ export class Projects {
* Body: { role: 'ADMIN' | 'MEMBER' }
*/
@Patch(':id/members/:userId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -345,7 +345,7 @@ export class Projects {
* DELETE /projects/:id/members/:userId
*/
@Delete(':id/members/:userId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+9 -9
View File
@@ -2,7 +2,7 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,7 +13,7 @@ export class Segments {
* List all segments for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -28,7 +28,7 @@ export class Segments {
* Get a specific segment by ID with member count
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -48,7 +48,7 @@ export class Segments {
* Get contacts that match a segment's filters
*/
@Get(':id/contacts')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -70,7 +70,7 @@ export class Segments {
* Create a new segment
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -99,7 +99,7 @@ export class Segments {
* Update a segment
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -129,7 +129,7 @@ export class Segments {
* Delete a segment
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -149,7 +149,7 @@ export class Segments {
* Recompute segment membership for all contacts
*/
@Post(':id/compute')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -169,7 +169,7 @@ export class Segments {
* Refresh segment member count
*/
@Post(':id/refresh')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+8 -8
View File
@@ -3,7 +3,7 @@ import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +15,7 @@ export class Templates {
* List all templates for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -34,7 +34,7 @@ export class Templates {
* Get a specific template by ID
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -54,7 +54,7 @@ export class Templates {
* Create a new template
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -98,7 +98,7 @@ export class Templates {
* Update a template
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -133,7 +133,7 @@ export class Templates {
* Delete a template
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -153,7 +153,7 @@ export class Templates {
* Duplicate a template
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -173,7 +173,7 @@ export class Templates {
* Get template usage statistics
*/
@Get(':id/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+2 -2
View File
@@ -4,7 +4,7 @@ import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -33,7 +33,7 @@ export class Uploads {
* Upload an image file to S3/Minio
*/
@Post('image')
@Middleware([requireAuth, upload.single('image')])
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+28 -15
View File
@@ -9,7 +9,7 @@ import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
@@ -20,7 +20,7 @@ import signale from 'signale';
@Controller('users')
export class Users {
@Get('@me')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -39,7 +39,7 @@ export class Users {
}
@Get('@me/projects')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -54,7 +54,7 @@ export class Users {
}
@Post('@me/projects')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -101,7 +101,7 @@ export class Users {
}
@Patch('@me/projects/:id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -133,7 +133,7 @@ export class Users {
}
@Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -185,11 +185,12 @@ export class Users {
}
@Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -245,6 +246,17 @@ export class Users {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
// Validate currency if provided
let checkoutCurrency: string | undefined;
if (currency && typeof currency === 'string') {
const validCurrencies = ['usd', 'eur', 'gbp'];
if (validCurrencies.includes(currency.toLowerCase())) {
checkoutCurrency = currency.toLowerCase();
} else {
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
}
}
// Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
@@ -253,6 +265,7 @@ export class Users {
customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
@@ -264,7 +277,7 @@ export class Users {
}
@Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -314,7 +327,7 @@ export class Users {
}
@Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -347,7 +360,7 @@ export class Users {
}
@Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -426,7 +439,7 @@ export class Users {
}
@Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -552,7 +565,7 @@ export class Users {
}
@Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -640,7 +653,7 @@ export class Users {
}
@Get('@me/projects/:id/security')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -673,7 +686,7 @@ export class Users {
}
@Post('@me/projects/:id/reset')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -759,7 +772,7 @@ export class Users {
}
@Delete('@me/projects/:id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+17 -17
View File
@@ -4,7 +4,7 @@ import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +15,7 @@ export class Workflows {
* List all workflows for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -35,7 +35,7 @@ export class Workflows {
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/
@Get('fields')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -58,7 +58,7 @@ export class Workflows {
* Get a specific workflow with all steps and transitions
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -78,7 +78,7 @@ export class Workflows {
* Create a new workflow
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -108,7 +108,7 @@ export class Workflows {
* Update a workflow
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -136,7 +136,7 @@ export class Workflows {
* Delete a workflow
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -156,7 +156,7 @@ export class Workflows {
* Add a step to a workflow
*/
@Post(':id/steps')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -188,7 +188,7 @@ export class Workflows {
* Update a workflow step
*/
@Patch(':id/steps/:stepId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -215,7 +215,7 @@ export class Workflows {
* Delete a workflow step
*/
@Delete(':id/steps/:stepId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -236,7 +236,7 @@ export class Workflows {
* Create a transition between steps
*/
@Post(':id/transitions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -266,7 +266,7 @@ export class Workflows {
* Delete a transition
*/
@Delete(':id/transitions/:transitionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -287,7 +287,7 @@ export class Workflows {
* Start a workflow execution for a contact
*/
@Post(':id/executions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -312,7 +312,7 @@ export class Workflows {
* List executions for a workflow
*/
@Get(':id/executions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -335,7 +335,7 @@ export class Workflows {
* Get a specific execution with details
*/
@Get(':id/executions/:executionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -356,7 +356,7 @@ export class Workflows {
* Cancel a workflow execution
*/
@Delete(':id/executions/:executionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
@@ -377,7 +377,7 @@ export class Workflows {
* Cancel all active executions for a workflow
*/
@Post(':id/executions/cancel-all')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
+1
View File
@@ -11,6 +11,7 @@ export enum ErrorCode {
FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
@@ -0,0 +1,281 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
* Verifies that CSV imports preserve subscription status correctly
*/
describe('Contact Import - Subscription Status Preservation', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Existing contacts', () => {
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
// Verify subscription status unchanged
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
// Verify subscription status unchanged (should NOT be re-subscribed)
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import with explicit subscribed=true
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import with explicit subscribed=false
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
describe('New contacts', () => {
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
const newEmail = '[email protected]';
// Simulate import without subscribed column (undefined)
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
const newEmail = '[email protected]';
// Simulate import with explicit subscribed=true
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
expect(contact.subscribed).toBe(true);
});
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
const newEmail = '[email protected]';
// Simulate import with explicit subscribed=false
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
expect(contact.subscribed).toBe(false);
});
});
describe('CSV parsing logic', () => {
it('should parse "true" string as boolean true', () => {
const subscribedValue = 'true';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "1" string as boolean true', () => {
const subscribedValue = '1';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "yes" string as boolean true', () => {
const subscribedValue = 'yes';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "false" string as boolean false', () => {
const subscribedValue = 'false';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "0" string as boolean false', () => {
const subscribedValue = '0';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "no" string as boolean false', () => {
const subscribedValue = 'no';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should handle empty string as undefined', () => {
const subscribedValue = '';
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
it('should handle undefined as undefined', () => {
const subscribedValue = undefined;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
});
describe('Data preservation', () => {
it('should preserve existing contact data while updating subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
data: {
firstName: 'Original',
lastName: 'Name',
plan: 'pro',
},
});
// Update only subscription via import
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be updated
expect(updated?.subscribed).toBe(true);
// Original data should be preserved
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('Original');
expect(data?.lastName).toBe('Name');
expect(data?.plan).toBe('pro');
});
it('should merge new data while preserving subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
data: {
firstName: 'John',
plan: 'pro',
},
});
// Import new data without changing subscription
await ContactService.upsert(
projectId,
contact.email,
{
lastName: 'Doe',
company: 'Acme Inc',
},
undefined,
);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be unchanged
expect(updated?.subscribed).toBe(false);
// Data should be merged
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('John'); // Preserved
expect(data?.plan).toBe('pro'); // Preserved
expect(data?.lastName).toBe('Doe'); // New
expect(data?.company).toBe('Acme Inc'); // New
});
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Background Job: Bulk Contact Action Processor
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {ContactService} from '../services/ContactService.js';
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
successCount: number;
failureCount: number;
errors: {contactId: string; email: string; error: string}[];
}
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 result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
successCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
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
}
} 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',
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
}
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;
}
},
{
connection: bulkContactQueue.opts.connection,
concurrency: 3, // Process max 3 bulk operations concurrently
},
);
worker.on('completed', job => {
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
});
return worker;
}
+9 -7
View File
@@ -52,9 +52,9 @@ export function createImportWorker() {
// Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV
// Parse CSV with column header normalization
const records = parse(csvContent, {
columns: true, // Use first row as header
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
skip_empty_lines: true,
trim: true,
relax_column_count: true, // Allow rows with different column counts
@@ -72,10 +72,10 @@ export function createImportWorker() {
// Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
// Validate that 'email' column exists
// Validate that 'email' column exists (case-insensitive)
const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
throw new Error('CSV must have an "email" column');
throw new Error('CSV must have an "email" column (case-insensitive)');
}
// Process contacts in batches
@@ -110,7 +110,7 @@ export function createImportWorker() {
continue;
}
// Extract subscribed field if present
// Extract subscribed field if present (case-insensitive)
const subscribedValue = record.subscribed;
let subscribed: boolean | undefined;
@@ -128,8 +128,10 @@ export function createImportWorker() {
const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact;
// Upsert contact with subscribed value from CSV if provided, otherwise default to true
await ContactService.upsert(projectId, email, data, subscribed ?? true);
// Upsert contact with subscribed value from CSV if provided
// For new contacts, ContactService.upsert defaults to true
// For existing contacts, only update if explicitly provided in CSV
await ContactService.upsert(projectId, email, data, subscribed);
result.successCount++;
if (isUpdate) {
+6
View File
@@ -10,6 +10,7 @@ import {Worker} from 'bullmq';
import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createBulkContactWorker} from './bulk-contact-processor.js';
import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
@@ -49,6 +50,11 @@ async function startWorkers() {
workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started');
// Start bulk contact action worker
const bulkContactWorker = createBulkContactWorker();
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
signale.success('[WORKER] Bulk contact action worker started');
// Start segment count worker
const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker});
+85 -32
View File
@@ -2,7 +2,7 @@ import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken';
import {JWT_SECRET} from '../app/constants.js';
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
@@ -119,6 +119,13 @@ export const requireProjectAccess = async (req: Request, res: Response, next: Ne
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
@@ -133,13 +140,6 @@ export const requireProjectAccess = async (req: Request, res: Response, next: Ne
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
next();
} catch (error) {
next(error);
@@ -189,6 +189,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -203,12 +209,6 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
next();
} catch (error) {
next(error);
@@ -258,6 +258,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -272,12 +278,6 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
next();
} catch (error) {
next(error);
@@ -325,6 +325,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -339,12 +345,6 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
return next();
}
@@ -378,6 +378,13 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
@@ -392,12 +399,58 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require email verification
* Must be used AFTER isAuthenticated or requireProjectAccess
* @param req
* @param res
* @param next
*/
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = res.locals.auth as AuthResponse;
if (auth.type === 'apiKey') {
return next();
}
if (!auth.userId) {
throw new NotAuthenticated();
}
const user = await prisma.user.findUnique({
where: {id: auth.userId},
select: {emailVerified: true, type: true},
});
if (!user) {
throw new NotAuthenticated();
}
// If platform email verification is disabled, skip check
if (!PLUNK_ENABLED) {
return next();
}
// OAuth users are always considered verified
if (user.type !== 'PASSWORD') {
return next();
}
// PASSWORD users must verify email
if (!user.emailVerified) {
throw new HttpException(
403,
'Please verify your email address to access this resource',
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
);
}
next();
} catch (error) {
+5
View File
@@ -454,6 +454,11 @@ export class ActivityService {
for (const email of emails) {
const baseMetadata = {
subject: email.subject,
body: email.body,
from: email.from,
fromName: email.fromName,
replyTo: email.replyTo,
toName: email.toName,
sourceType: email.sourceType,
campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name,
+191 -4
View File
@@ -1,4 +1,5 @@
import {type Contact, Prisma} from '@plunk/db';
import {isValidLanguageCode} from '@plunk/shared';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {prisma} from '../database/prisma.js';
@@ -224,11 +225,27 @@ export class ContactService {
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved fields
if (key === 'plunk_id' || key === 'plunk_email') {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
// Validate locale field (special user-settable field)
if (key === 'locale') {
if (typeof value === 'string') {
if (!isValidLanguageCode(value)) {
throw new HttpException(
400,
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
);
}
} else if (value !== null && value !== undefined) {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
@@ -297,11 +314,18 @@ export class ContactService {
Object.assign(mergedData, contact.data);
}
// Explicitly expose locale as a predefined field (available in templates)
// This ensures locale is always accessible even if not in contact.data
if (mergedData.locale === undefined) {
mergedData.locale = null;
}
// Add temporary (non-persistent) data
if (temporaryData) {
for (const [key, value] of Object.entries(temporaryData)) {
// Skip reserved fields
if (key === 'plunk_id' || key === 'plunk_email') {
// Skip reserved system-generated fields
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
@@ -339,6 +363,25 @@ export class ContactService {
return contact;
}
/**
* Get project by contact ID
* Used to fetch project settings for public endpoints
*/
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
select: {
project: {
select: {
language: true,
},
},
},
});
return contact?.project || null;
}
/**
* PUBLIC: Subscribe a contact
*/
@@ -679,4 +722,148 @@ export class ContactService {
return false;
}
/**
* Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches
*/
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
// Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
if (unsubscribedIds.length === 0) {
return {updated: 0};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
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};
}
/**
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
if (subscribedIds.length === 0) {
return {updated: 0};
}
const result = await prisma.contact.updateMany({
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};
}
/**
* Bulk delete contacts
*/
public static async bulkDelete(
projectId: string,
contactIds: string[],
): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({
where: {
id: {in: contactIds},
projectId,
},
});
return {deleted: result.count};
}
/**
* Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling
*
* @private
*/
private static async trackEventsSequentially(
projectId: string,
eventName: string,
contactIds: string[],
): Promise<void> {
for (const contactId of contactIds) {
try {
await EventService.trackEvent(projectId, eventName, contactId);
} catch (error) {
// Log error but continue processing remaining events
// Suppress logging in test environments to reduce noise from cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
}
}
}
}
}
+24 -5
View File
@@ -5,7 +5,7 @@ import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {renderTemplate} from '@plunk/shared';
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
@@ -603,19 +603,38 @@ export class EmailService {
let html = content;
const unsubscribeHtml = includeUnsubscribe
? `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
? (() => {
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
// Get translator for contact's locale or project's language
const translator = createTranslatorSync(contactLocale || project.language || 'en');
const unsubscribeText = translator.t('email.footer.unsubscribeText', {
projectName: project.name,
});
const updatePreferencesText = translator.t('email.footer.updatePreferences');
return `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
<tbody>
<tr>
<td>
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">update your preferences</a>.
${unsubscribeText}
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
</p>
</td>
</tr>
</tbody>
</table>`
</table>`;
})()
: '';
// Add Plunk badge if billing is enabled and project has no subscription (free tier)
@@ -0,0 +1,160 @@
import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker';
import {redis} from '../database/redis.js';
export interface EmailVerificationResult {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
const DISPOSABLE_DOMAINS_URL =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null;
/**
* Fetch and cache the disposable domains list from GitHub
* Uses Redis for caching with 24-hour TTL
* Falls back to in-memory cache if Redis fails
*/
private static async getDisposableDomains(): Promise<Set<string>> {
// Return in-memory cache if available
if (this.disposableDomainsSet) {
return this.disposableDomainsSet;
}
try {
// Try to get from Redis cache first
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
if (cached) {
const domains = JSON.parse(cached) as string[];
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
}
// Fetch from GitHub if not in cache
const response = await fetch(DISPOSABLE_DOMAINS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
} catch (error) {
console.error('Error fetching disposable domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
const disposableDomains = await this.getDisposableDomains();
return disposableDomains.has(domain.toLowerCase());
}
/**
* Verify an email address
* - Checks if domain exists (DNS A/AAAA records)
* - Checks for MX records
* - Detects disposable email addresses
* - Suggests corrections for common typos
*/
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
const result: EmailVerificationResult = {
email,
valid: true,
isDisposable: false,
isTypo: false,
isPlusAddressed: false,
domainExists: false,
hasMxRecords: false,
reasons: [],
};
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
result.valid = false;
result.reasons.push('Invalid email format');
return result;
}
const domain = emailParts[1]!; // Safe to assert, we already validated length
// Check if email is from a disposable domain using GitHub list
result.isDisposable = await this.isDisposableDomain(domain);
// Check for plus addressing
result.isPlusAddressed = emailParts[0]!.includes('+');
// Check for common typos and suggest corrections
const typoCheck = run({email});
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
result.suggestedEmail = typoCheck.full;
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
result.isTypo = true;
}
// Check if domain exists (has any DNS records)
try {
await dns.resolve(domain, 'A');
result.domainExists = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.domainExists = true;
} catch {
result.domainExists = false;
result.valid = false;
result.reasons.push('Domain does not exist');
}
}
// Check MX records (only if domain exists)
if (result.domainExists) {
try {
const mxRecords = await dns.resolveMx(domain);
result.hasMxRecords = mxRecords && mxRecords.length > 0;
if (!result.hasMxRecords) {
result.valid = false;
result.reasons.push('No MX records found for domain');
}
} catch {
result.hasMxRecords = false;
result.valid = false;
result.reasons.push('No MX records found for domain');
}
}
// If no issues were found, add a success reason
if (result.valid && result.reasons.length === 0) {
result.reasons.push('Email appears to be valid');
}
return result;
}
}
-1
View File
@@ -5,7 +5,6 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {ValidationError} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
+69
View File
@@ -52,6 +52,12 @@ export interface ApiRequestCleanupJobData {
// Empty - cleans up old API request logs
}
export interface BulkContactActionJobData {
projectId: string;
contactIds: string[];
operation: 'subscribe' | 'unsubscribe' | 'delete';
}
/**
* Queue Configuration
*/
@@ -181,6 +187,19 @@ export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-r
},
});
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for bulk operations
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed bulk operations
removeOnFail: 100, // Keep last 100 failed bulk operations
},
});
/**
* Queue Service - Centralized queue management
*/
@@ -328,6 +347,48 @@ export class QueueService {
};
}
/**
* Queue bulk contact action job
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
);
}
/**
* Get bulk action job status and progress
*/
public static async getBulkActionJobStatus(jobId: string) {
const job = await bulkContactQueue.getJob(jobId);
if (!job) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue segment count update job
*/
@@ -354,6 +415,7 @@ export class QueueService {
segmentCountCounts,
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
@@ -363,6 +425,7 @@ export class QueueService {
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
@@ -374,6 +437,7 @@ export class QueueService {
segmentCount: segmentCountCounts,
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
};
}
@@ -390,6 +454,7 @@ export class QueueService {
segmentCountQueue.pause(),
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
]);
}
@@ -406,6 +471,7 @@ export class QueueService {
segmentCountQueue.resume(),
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
]);
}
@@ -430,6 +496,8 @@ export class QueueService {
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
]);
}
@@ -514,6 +582,7 @@ export class QueueService {
segmentCountQueue.close(),
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
]);
}
}
+98 -2
View File
@@ -458,7 +458,7 @@ export class SegmentService {
// Handle JSON field paths (e.g., "data.plan")
if (field.startsWith('data.')) {
const jsonPath = field.substring(5); // Remove "data." prefix
return this.buildJsonFieldCondition(jsonPath, operator, value);
return this.buildJsonFieldCondition(jsonPath, operator, value, unit);
}
// Handle regular fields
@@ -638,13 +638,38 @@ export class SegmentService {
/**
* Build condition for JSON fields (stored in contact.data)
*/
private static buildJsonFieldCondition(jsonPath: string, operator: string, value: unknown): Prisma.ContactWhereInput {
private static buildJsonFieldCondition(
jsonPath: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
const path = jsonPath.split('.');
switch (operator) {
case 'equals':
// For date strings, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [
{data: {path, gte: startOfDay as Prisma.InputJsonValue}},
{data: {path, lt: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {data: {path, equals: value as Prisma.InputJsonValue}};
case 'notEquals':
// For date strings, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [
{data: {path, lt: startOfDay as Prisma.InputJsonValue}},
{data: {path, gte: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
case 'contains':
return {data: {path, string_contains: String(value)}};
@@ -670,6 +695,20 @@ export class SegmentService {
return {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
};
case 'within': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
throw new HttpException(400, 'Unit is required for "within" operator');
}
// Calculate the "since" date (X time units ago from now)
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
// Use ISO string for lexicographic comparison in JSON
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
}
default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
}
@@ -721,6 +760,28 @@ export class SegmentService {
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'equals': {
// For date fields, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [{[field]: {gte: new Date(startOfDay)}}, {[field]: {lt: new Date(startOfNextDay)}}],
};
}
// Exact timestamp match if not a date string
return {[field]: new Date(value as string | number | Date)};
}
case 'notEquals': {
// For date fields, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [{[field]: {lt: new Date(startOfDay)}}, {[field]: {gte: new Date(startOfNextDay)}}],
};
}
// Exclude exact timestamp if not a date string
return {NOT: {[field]: new Date(value as string | number | Date)}};
}
case 'greaterThan':
return {[field]: {gt: new Date(value as string | number | Date)}};
case 'lessThan':
@@ -762,6 +823,41 @@ export class SegmentService {
}
}
/**
* Check if a value is a date string in YYYY-MM-DD format
*/
private static isDateString(value: unknown): boolean {
if (typeof value !== 'string') return false;
// Match YYYY-MM-DD format (with optional time component)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T|$)/;
if (!dateRegex.test(value)) return false;
// Verify it's a valid date
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* Get date range for a date string (start of day to start of next day in UTC)
* @param value - Date string in YYYY-MM-DD format
* @returns Object with startOfDay and startOfNextDay as ISO strings
*/
private static getDateRange(value: string): {startOfDay: string; startOfNextDay: string} {
// Extract just the date part (YYYY-MM-DD)
const dateStr = value.split('T')[0];
const startOfDay = `${dateStr}T00:00:00.000Z`;
if (!dateStr) {
throw new HttpException(400, `Invalid date string: ${value}`);
}
// Calculate start of next day
const nextDay = new Date(dateStr);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const startOfNextDay = nextDay.toISOString().split('T')[0] + 'T00:00:00.000Z';
return {startOfDay, startOfNextDay};
}
/**
* Build condition for event-based filters
* Uses Prisma relations to efficiently query contacts who triggered specific events
@@ -439,4 +439,271 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
expect(unsubscribed?.subscribed).toBe(false);
});
});
describe('Bulk Contact Operations', () => {
describe('bulkSubscribe', () => {
it('should subscribe multiple unsubscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const contact3 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => c.subscribed)).toBe(true);
});
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [
unsubscribed1.id,
unsubscribed2.id,
alreadySubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: false});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(true);
expect(otherContactAfter?.subscribed).toBe(false);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkSubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
);
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkSubscribe(projectId, contactIds);
expect(result.updated).toBe(150);
const updatedContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
});
});
describe('bulkUnsubscribe', () => {
it('should unsubscribe multiple subscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const contact3 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => !c.subscribed)).toBe(true);
});
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
const subscribed1 = await factories.createContact({projectId, subscribed: true});
const subscribed2 = await factories.createContact({projectId, subscribed: true});
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [
subscribed1.id,
subscribed2.id,
alreadyUnsubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: true});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(false);
expect(otherContactAfter?.subscribed).toBe(true);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
});
describe('bulkDelete', () => {
it('should delete multiple contacts', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
const contact3 = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.deleted).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts).toHaveLength(0);
});
it('should only delete contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId});
const otherContact = await factories.createContact({projectId: otherProject.id});
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
expect(result.deleted).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter).toBeNull();
expect(otherContactAfter).not.toBeNull();
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkDelete(projectId, []);
expect(result.deleted).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.deleted).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkDelete(projectId, contactIds);
expect(result.deleted).toBe(200);
const remainingContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(remainingContacts).toHaveLength(0);
});
it('should delete both subscribed and unsubscribed contacts', async () => {
const subscribed = await factories.createContact({projectId, subscribed: true});
const unsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
expect(result.deleted).toBe(2);
});
it('should handle partial matches (some exist, some do not)', async () => {
const existingContact = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
expect(result.deleted).toBe(1);
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
expect(contact).toBeNull();
});
});
describe('Bulk Operations - Project Isolation', () => {
it('should never leak contacts between projects in bulk operations', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [
p1Contact1.id,
p1Contact2.id,
p2Contact1.id,
p2Contact2.id,
]);
const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id},
});
const p2ContactsAfter = await prisma.contact.findMany({
where: {projectId: project2.id},
});
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
});
});
});
});
@@ -1,6 +1,6 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {SegmentService} from '../SegmentService';
import {factories} from '../../../../../test/helpers';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Comprehensive Operator Tests for Segment Filtering
@@ -760,6 +760,226 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
expect(ids).toContain(justNow.id);
});
});
describe('within operator for JSON date fields', () => {
it('should match contacts with JSON date field within specified days', async () => {
// Create contact with recent date in JSON field
const recentDate = new Date();
const match = await factories.createContact({
projectId,
data: {
subscriptionEndDate: recentDate.toISOString(),
},
});
// Create contact with old date
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10);
await factories.createContact({
projectId,
data: {
subscriptionEndDate: oldDate.toISOString(),
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match contacts with JSON date field within specified hours', async () => {
const recentDate = new Date();
const match = await factories.createContact({
projectId,
data: {
lastLoginAt: recentDate.toISOString(),
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.lastLoginAt',
operator: 'within',
value: 24,
unit: 'hours',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match contacts with JSON date field within specified minutes', async () => {
const justNow = new Date();
const match = await factories.createContact({
projectId,
data: {
verifiedAt: justNow.toISOString(),
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.verifiedAt',
operator: 'within',
value: 60,
unit: 'minutes',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should NOT match contacts with JSON date field outside the time range', async () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10);
await factories.createContact({
projectId,
data: {
subscriptionEndDate: oldDate.toISOString(),
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should NOT match contacts where JSON date field does not exist', async () => {
await factories.createContact({
projectId,
data: {
otherField: 'value',
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should handle null values in JSON date fields gracefully', async () => {
await factories.createContact({
projectId,
data: {
subscriptionEndDate: null,
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should work correctly with combined filters (AND logic)', async () => {
const recentDate = new Date();
const match = await factories.createContact({
projectId,
subscribed: true,
data: {
plan: 'premium',
subscriptionEndDate: recentDate.toISOString(),
},
});
await factories.createContact({
projectId,
subscribed: false,
data: {
plan: 'premium',
subscriptionEndDate: recentDate.toISOString(),
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{field: 'subscribed', operator: 'equals', value: true},
{field: 'data.plan', operator: 'equals', value: 'premium'},
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should require unit parameter for within operator', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'within',
value: 3,
// unit intentionally omitted
} as any,
],
},
],
},
}),
).rejects.toThrow(/operator requires a unit/i);
});
});
});
// ========================================
@@ -812,6 +1032,229 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
});
// ========================================
// DATE-ONLY COMPARISON (equals/notEquals)
// ========================================
describe('Date-Only Comparison for equals/notEquals', () => {
describe('JSON date fields', () => {
it('should match contacts with date on the same day (equals) - ignoring time', async () => {
const targetDate = '2025-01-15';
const match1 = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-15T08:30:00.000Z', // Morning
},
});
const match2 = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-15T18:45:30.000Z', // Evening
},
});
const noMatch = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-16T00:00:00.000Z', // Next day
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'equals',
value: targetDate,
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
});
it('should exclude contacts with date on the specified day (notEquals)', async () => {
const targetDate = '2025-01-15';
const match1 = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-14T23:59:59.999Z', // Day before
},
});
const match2 = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-16T00:00:00.000Z', // Day after
},
});
const noMatch = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-15T12:00:00.000Z', // Same day
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'notEquals',
value: targetDate,
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
});
it('should handle date string with time component (extracts date only)', async () => {
const targetDate = '2025-01-15T00:00:00.000Z'; // Has time component
const match = await factories.createContact({
projectId,
data: {
subscriptionEndDate: '2025-01-15T23:59:59.999Z', // End of day
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.subscriptionEndDate',
operator: 'equals',
value: targetDate,
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(match.id);
});
it('should still do exact match for non-date strings', async () => {
const match = await factories.createContact({
projectId,
data: {
plan: 'premium',
},
});
await factories.createContact({
projectId,
data: {
plan: 'basic',
},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'data.plan',
operator: 'equals',
value: 'premium',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('Native date fields (createdAt)', () => {
it('should match contacts created on the same day (equals) - ignoring time', async () => {
// Create contacts on specific dates
const jan15Morning = new Date('2025-01-15T08:30:00.000Z');
const jan15Evening = new Date('2025-01-15T18:45:30.000Z');
const jan16 = new Date('2025-01-16T00:00:00.000Z');
const prisma = getPrismaClient();
const match1 = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: match1.id},
data: {createdAt: jan15Morning},
});
const match2 = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: match2.id},
data: {createdAt: jan15Evening},
});
const noMatch = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: noMatch.id},
data: {createdAt: jan16},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'equals',
value: '2025-01-15',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
});
it('should exclude contacts created on the specified day (notEquals)', async () => {
const jan14 = new Date('2025-01-14T23:59:59.999Z');
const jan15 = new Date('2025-01-15T12:00:00.000Z');
const jan16 = new Date('2025-01-16T00:00:00.000Z');
const prisma = getPrismaClient();
const match1 = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: match1.id},
data: {createdAt: jan14},
});
const match2 = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: match2.id},
data: {createdAt: jan16},
});
const noMatch = await factories.createContact({projectId});
await prisma.contact.update({
where: {id: noMatch.id},
data: {createdAt: jan15},
});
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'notEquals',
value: '2025-01-15',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
});
});
});
// ========================================
// COMBINED OPERATORS (AND logic)
// ========================================
@@ -890,4 +1333,403 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
expect(result.contacts[0].id).toBe(match.id);
});
});
// ========================================
// EVENT OPERATORS
// ========================================
describe('Event Operators', () => {
describe('triggered operator', () => {
it('should match contacts who have triggered the event', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
// Create event for match contact
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'purchase',
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should not match contacts who have not triggered the event', async () => {
const prisma = getPrismaClient();
const contact1 = await factories.createContact({projectId});
// Create different event
await prisma.event.create({
data: {
projectId,
contactId: contact1.id,
name: 'signup',
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should match contacts with multiple occurrences of the same event', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
// Create multiple events
await prisma.event.createMany({
data: [
{projectId, contactId: match.id, name: 'purchase'},
{projectId, contactId: match.id, name: 'purchase'},
{projectId, contactId: match.id, name: 'purchase'},
],
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('triggeredWithin operator', () => {
it('should match contacts with event within time range (days)', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const noMatch = await factories.createContact({projectId});
const now = new Date();
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const eightDaysAgo = new Date(now.getTime() - 8 * 24 * 60 * 60 * 1000);
// Recent event
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'purchase',
createdAt: twoDaysAgo,
},
});
// Old event
await prisma.event.create({
data: {
projectId,
contactId: noMatch.id,
name: 'purchase',
createdAt: eightDaysAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggeredWithin', value: 7, unit: 'days'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match contacts with event within time range (hours)', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const noMatch = await factories.createContact({projectId});
const now = new Date();
const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000);
const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'login',
createdAt: twoHoursAgo,
},
});
await prisma.event.create({
data: {
projectId,
contactId: noMatch.id,
name: 'login',
createdAt: sixHoursAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.login', operator: 'triggeredWithin', value: 4, unit: 'hours'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match contacts with event within time range (minutes)', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const noMatch = await factories.createContact({projectId});
const now = new Date();
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);
const twentyMinutesAgo = new Date(now.getTime() - 20 * 60 * 1000);
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'click',
createdAt: fiveMinutesAgo,
},
});
await prisma.event.create({
data: {
projectId,
contactId: noMatch.id,
name: 'click',
createdAt: twentyMinutesAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.click', operator: 'triggeredWithin', value: 10, unit: 'minutes'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should handle events at exact boundary', async () => {
const prisma = getPrismaClient();
const contact = await factories.createContact({projectId});
const now = new Date();
const exactlySevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
await prisma.event.create({
data: {
projectId,
contactId: contact.id,
name: 'purchase',
createdAt: exactlySevenDaysAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggeredWithin', value: 7, unit: 'days'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
// Should not match because events at exact boundary are excluded
expect(result.contacts).toHaveLength(0);
});
it('should not match contacts with event outside time range', async () => {
const prisma = getPrismaClient();
const contact = await factories.createContact({projectId});
const now = new Date();
const tenDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000);
await prisma.event.create({
data: {
projectId,
contactId: contact.id,
name: 'purchase',
createdAt: tenDaysAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggeredWithin', value: 7, unit: 'days'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should match contact if any of their events is within range', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const now = new Date();
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Old event
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'purchase',
createdAt: thirtyDaysAgo,
},
});
// Recent event
await prisma.event.create({
data: {
projectId,
contactId: match.id,
name: 'purchase',
createdAt: twoDaysAgo,
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'triggeredWithin', value: 7, unit: 'days'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('notTriggered operator', () => {
it('should match contacts who have never triggered the event', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const noMatch = await factories.createContact({projectId});
// Only noMatch has the event
await prisma.event.create({
data: {
projectId,
contactId: noMatch.id,
name: 'purchase',
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'notTriggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should not match contacts who have triggered the event', async () => {
const prisma = getPrismaClient();
const contact = await factories.createContact({projectId});
await prisma.event.create({
data: {
projectId,
contactId: contact.id,
name: 'purchase',
},
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'notTriggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should match contacts with other events but not the target event', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
// Create different events
await prisma.event.createMany({
data: [
{projectId, contactId: match.id, name: 'signup'},
{projectId, contactId: match.id, name: 'login'},
],
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'event.purchase', operator: 'notTriggered'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('Event operators with combined filters', () => {
it('should combine event filter with contact field filter (AND)', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({
projectId,
data: {plan: 'premium'},
});
const noMatch1 = await factories.createContact({
projectId,
data: {plan: 'basic'},
});
// match and noMatch1 have the event, but only match has premium plan
await prisma.event.create({
data: {projectId, contactId: match.id, name: 'purchase'},
});
await prisma.event.create({
data: {projectId, contactId: noMatch1.id, name: 'purchase'},
});
const segment = await factories.createSegment(projectId, {
filters: [
{field: 'event.purchase', operator: 'triggered'},
{field: 'data.plan', operator: 'equals', value: 'premium'},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should combine multiple event filters', async () => {
const prisma = getPrismaClient();
const match = await factories.createContact({projectId});
const noMatch = await factories.createContact({projectId});
// match has both events
await prisma.event.createMany({
data: [
{projectId, contactId: match.id, name: 'signup'},
{projectId, contactId: match.id, name: 'purchase'},
],
});
// noMatch only has signup
await prisma.event.create({
data: {projectId, contactId: noMatch.id, name: 'signup'},
});
const segment = await factories.createSegment(projectId, {
filters: [
{field: 'event.signup', operator: 'triggered'},
{field: 'event.purchase', operator: 'triggered'},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
});
});
+12
View File
@@ -6,6 +6,18 @@ export const Keys = {
email(email: string): string {
return `account:${email}`;
},
emailVerificationToken(token: string): string {
return `auth:email_verification:${token}`;
},
passwordResetToken(token: string): string {
return `auth:password_reset:${token}`;
},
emailVerificationRateLimit(userId: string): string {
return `auth:email_verification_rate:${userId}`;
},
passwordResetRateLimit(email: string): string {
return `auth:password_reset_rate:${email}`;
},
},
Domain: {
id(id: string): string {
+1 -1
View File
@@ -1,5 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
generateRobotsTxt: true,
};
+12
View File
@@ -14,6 +14,18 @@
"@plunk/db": "*",
"@plunk/shared": "*",
"@plunk/ui": "*",
"@tiptap/core": "^3.11.0",
"@tiptap/extension-color": "^3.11.0",
"@tiptap/extension-image": "^3.11.0",
"@tiptap/extension-link": "^3.11.0",
"@tiptap/extension-placeholder": "^3.11.0",
"@tiptap/extension-text-align": "^3.11.0",
"@tiptap/extension-text-style": "^3.11.0",
"@tiptap/extension-underline": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/react": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.10",
"next-seo": "^6.6.0",
+66
View File
@@ -0,0 +1,66 @@
import {motion} from 'framer-motion';
import React, {useState} from 'react';
import {Check, Copy} from 'lucide-react';
interface CodeBlockProps {
code: string;
language?: string;
title?: string;
showCopy?: boolean;
}
/**
* Reusable code block component with syntax highlighting styling and copy functionality
*/
export function CodeBlock({code, language = 'javascript', title, showCopy = true}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<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={'group relative overflow-hidden rounded-xl border border-neutral-200 bg-neutral-900 w-full max-w-full'}
>
{(title || showCopy) && (
<div className={'flex items-center justify-between border-b border-neutral-800 bg-neutral-900 px-6 py-3'}>
{title && <span className={'text-sm font-medium text-neutral-400'}>{title}</span>}
{!title && <span className={'text-xs font-medium text-neutral-500 uppercase'}>{language}</span>}
{showCopy && (
<button
onClick={handleCopy}
className={
'flex items-center gap-2 rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs font-medium text-neutral-300 transition hover:bg-neutral-700 hover:text-white'
}
aria-label="Copy code"
>
{copied ? (
<>
<Check className="h-3 w-3" />
Copied!
</>
) : (
<>
<Copy className="h-3 w-3" />
Copy
</>
)}
</button>
)}
</div>
)}
<pre className={'overflow-x-auto p-6 text-neutral-100 w-full max-w-full'} style={{boxSizing: 'border-box'}}>
<code className={'font-mono text-sm leading-relaxed'} style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word'}}>
{code}
</code>
</pre>
</motion.div>
);
}
@@ -0,0 +1,80 @@
import {Check, X} from 'lucide-react';
import {motion} from 'framer-motion';
import React from 'react';
export interface ComparisonRow {
feature: string;
plunk: boolean | string;
competitor: boolean | string;
}
interface ComparisonTableProps {
competitorName: string;
rows: ComparisonRow[];
}
/**
* Reusable comparison table component for competitor pages
*/
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
return (
<div className={'overflow-hidden rounded-xl border border-neutral-200'}>
{/* Header */}
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
<div className={'bg-white p-6'}>
<span className={'text-sm font-semibold text-neutral-900'}>Feature</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
</div>
</div>
{/* Rows */}
<div className={'grid gap-px bg-neutral-200'}>
{rows.map((row, index) => (
<motion.div
key={row.feature}
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-3 gap-px bg-neutral-200'}
>
<div className={'bg-white p-6'}>
<span className={'text-sm text-neutral-600'}>{row.feature}</span>
</div>
<div className={'bg-white p-6'}>
<div className={'flex justify-center'}>
{typeof row.plunk === 'boolean' ? (
row.plunk ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
)}
</div>
</div>
<div className={'bg-white p-6'}>
<div className={'flex justify-center'}>
{typeof row.competitor === 'boolean' ? (
row.competitor ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
)}
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import {motion} from 'framer-motion';
import Script from 'next/script';
import React from 'react';
export interface FAQ {
question: string;
answer: string;
}
interface FAQSectionProps {
faqs: FAQ[];
schemaId?: string;
}
/**
* Reusable FAQ section component with structured data support
*/
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
return (
<>
<Script
id={schemaId}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
'mainEntity': faqs.map(faq => ({
'@type': 'Question',
'name': faq.question,
'acceptedAnswer': {
'@type': 'Answer',
'text': faq.answer,
},
})),
}),
}}
/>
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl'}
>
<h2 className={'mb-16 text-center text-5xl font-bold tracking-tight text-neutral-900'}>
Frequently asked questions
</h2>
<div className={'space-y-8'}>
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'border-b border-neutral-200 pb-8 last:border-b-0'}
>
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
</motion.div>
))}
</div>
</motion.div>
</section>
</>
);
}
@@ -67,7 +67,10 @@ export default function Footer() {
</Link>
</li>
<li>
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
<Link
href={'/made-by-humans'}
className="text-sm text-neutral-600 transition hover:text-neutral-900"
>
Made by humans
</Link>
</li>
@@ -131,9 +134,7 @@ export default function Footer() {
</div>
<div className="mt-16 border-t border-neutral-200 pt-8">
<p className="text-sm text-neutral-500">
&copy; {new Date().getFullYear()} Plunk. All rights reserved.
</p>
<p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p>
</div>
</div>
</footer>
+36 -18
View File
@@ -12,7 +12,7 @@ export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<nav className={'top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
<nav className={'relative top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
<div className={'z-40 py-6'}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-12">
@@ -25,19 +25,34 @@ export default function Navbar() {
</Link>
</div>
<div className="hidden items-center gap-8 md:flex">
<Link href={'/made-by-humans'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<Link
href={'/made-by-humans'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
By humans
</Link>
<Link href={'/pricing'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<Link
href={'/pricing'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Pricing
</Link>
<Link
href={'/guides'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Guides
</Link>
<Link
href={WIKI_URI}
target={'_blank'}
rel={'noreferrer'}
className={'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
className={
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
}
>
Docs
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -47,32 +62,27 @@ export default function Navbar() {
strokeWidth="2"
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19.25 9.25V4.75H14.75"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 5L11.75 12.25"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
</svg>
</Link>
</div>
</div>
<div className="hidden items-center gap-6 md:flex">
<a href={`${DASHBOARD_URI}/auth/login`} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<a
href={`${DASHBOARD_URI}/auth/login`}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Sign in
</a>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'}
className={
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
}
>
Get started
</motion.a>
@@ -146,6 +156,14 @@ export default function Navbar() {
Pricing
</Link>
<Link
href={'/guides'}
onClick={() => setMobileOpen(false)}
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
>
Guides
</Link>
<a
href={WIKI_URI}
target={'_blank'}
@@ -0,0 +1,244 @@
import {Footer, Navbar} from '../';
import {motion} from 'framer-motion';
import React, {ReactNode, useLayoutEffect, useState} from 'react';
import Link from 'next/link';
import {ArticleJsonLd, BreadcrumbJsonLd, NextSeo} from 'next-seo';
import {Calendar, Clock} from 'lucide-react';
interface GuideLayoutProps {
title: string;
description: string;
lastUpdated: string;
readTime: string;
children: ReactNode;
canonical?: string;
ogImage?: string;
}
/**
* Reusable layout for educational guide pages
*/
export function GuideLayout({
title,
description,
lastUpdated,
readTime,
children,
canonical,
ogImage = 'https://www.useplunk.com/assets/card.png',
}: GuideLayoutProps) {
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
// Extract headings for table of contents
useLayoutEffect(() => {
const elements = Array.from(document.querySelectorAll('h2, h3'));
// Generate IDs for headings that don't have them
const headingData = elements.map(element => {
let id = element.id;
if (!id) {
// Generate ID from text content
id = (element.textContent || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
element.id = id;
}
return {
id,
text: element.textContent || '',
level: parseInt(element.tagName.substring(1)),
};
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadings(headingData);
// Set up intersection observer for active heading
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{rootMargin: '-100px 0px -80% 0px'},
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
// Generate breadcrumb items
const breadcrumbItems = [
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
{position: 3, name: title, item: canonical || ''},
];
return (
<>
<NextSeo
title={`${title} | Plunk`}
description={description}
canonical={canonical}
openGraph={{
title: `${title} | Plunk`,
description: description,
url: canonical,
type: 'article',
images: [{url: ogImage, alt: title}],
article: {
publishedTime: lastUpdated,
modifiedTime: lastUpdated,
authors: ['Plunk'],
},
}}
/>
<ArticleJsonLd
type="Article"
url={canonical || ''}
title={title}
images={[ogImage]}
datePublished={lastUpdated}
dateModified={lastUpdated}
authorName="Plunk"
description={description}
/>
<BreadcrumbJsonLd itemListElements={breadcrumbItems} />
<Navbar />
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
<div className={'flex flex-col lg:flex-row gap-8 lg:gap-12 py-8 sm:py-16 w-full'}>
{/* Main Content */}
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
{/* Breadcrumbs */}
<nav className={'mb-6 sm:mb-8 w-full overflow-x-auto'}>
<ol
className={
'flex flex-wrap items-center gap-1 sm:gap-2 text-xs sm:text-sm text-neutral-600 whitespace-normal'
}
>
<li className={'truncate max-w-[90vw]'}>
<Link href="/" className={'hover:text-neutral-900'}>
Home
</Link>
</li>
<li>/</li>
<li className={'truncate max-w-[90vw]'}>
<Link href="/guides" className={'hover:text-neutral-900'}>
Guides
</Link>
</li>
<li>/</li>
<li className={'text-neutral-900 font-medium truncate max-w-[90vw]'}>{title}</li>
</ol>
</nav>
{/* Header */}
<motion.header
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-8 sm:mb-12 w-full'}
>
<h1 className={'text-2xl sm:text-4xl font-bold tracking-tight text-neutral-900 break-words max-w-full'}>
{title}
</h1>
<p
className={'mt-4 sm:mt-6 text-base sm:text-lg text-neutral-600 leading-relaxed break-words max-w-full'}
>
{description}
</p>
<div
className={
'mt-6 sm:mt-8 flex flex-wrap items-center gap-4 sm:gap-6 text-xs sm:text-sm text-neutral-600'
}
>
<div className={'flex items-center gap-2'}>
<Calendar className="h-4 w-4" />
<span>
Updated{' '}
{new Date(lastUpdated).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className={'flex items-center gap-2'}>
<Clock className="h-4 w-4" />
<span>{readTime} read</span>
</div>
</div>
</motion.header>
{/* Content */}
<div className={'prose prose-neutral max-w-full'} style={{overflowX: 'visible'}}>
{/* Responsive table and code block styles */}
<style>{`
.prose table { display: block; width: 100%; overflow-x: auto; }
.prose th, .prose td { white-space: normal; word-break: break-word; }
.prose pre, .prose code { max-width: 100vw; overflow-x: auto; word-break: break-word; }
.prose img { max-width: 100%; height: auto; }
`}</style>
{children}
</div>
</article>
{/* Table of Contents - Desktop Only */}
{headings.length > 0 && (
<aside className={'hidden lg:block w-64 shrink-0 sticky top-24 self-start'}>
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>On this page</h2>
<nav>
<ul className={'space-y-1'}>
{headings.map(heading => (
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const offset = 100; // Account for fixed header
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - offset,
behavior: 'smooth',
});
}
}}
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
heading.level === 2
? activeId === heading.id
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
: activeId === heading.id
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</aside>
)}
</div>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,82 @@
import React, {ReactNode} from 'react';
import {AlertTriangle, CheckCircle2, Info, Lightbulb} from 'lucide-react';
type InfoBoxType = 'info' | 'warning' | 'tip' | 'success';
interface InfoBoxProps {
type?: InfoBoxType;
title?: string;
children: ReactNode;
className?: string;
}
const infoBoxConfig: Record<
InfoBoxType,
{
icon: React.ComponentType<{className?: string}>;
borderColor: string;
bgColor: string;
iconColor: string;
titleColor: string;
}
> = {
info: {
icon: Info,
borderColor: 'border-blue-200',
bgColor: 'bg-blue-50',
iconColor: 'text-blue-600',
titleColor: 'text-blue-900',
},
warning: {
icon: AlertTriangle,
borderColor: 'border-amber-200',
bgColor: 'bg-amber-50',
iconColor: 'text-amber-600',
titleColor: 'text-amber-900',
},
tip: {
icon: Lightbulb,
borderColor: 'border-purple-200',
bgColor: 'bg-purple-50',
iconColor: 'text-purple-600',
titleColor: 'text-purple-900',
},
success: {
icon: CheckCircle2,
borderColor: 'border-green-200',
bgColor: 'bg-green-50',
iconColor: 'text-green-600',
titleColor: 'text-green-900',
},
};
/**
* InfoBox component for displaying tips, warnings, notes, and other callouts in guides
*/
export function InfoBox({type = 'info', title, children, className}: InfoBoxProps) {
const config = infoBoxConfig[type];
const Icon = config.icon;
const defaultTitles: Record<InfoBoxType, string> = {
info: 'Note',
warning: 'Warning',
tip: 'Tip',
success: 'Success',
};
return (
<div className={`rounded-xl border ${config.borderColor} ${config.bgColor} p-6 my-6 ${className || ''}`}>
<div className={'flex gap-4'}>
<div className={'shrink-0'}>
<Icon className={`h-5 w-5 ${config.iconColor}`} />
</div>
<div>
{(title || defaultTitles[type]) && (
<h4 className={`not-prose font-semibold ${config.titleColor}`}>{title || defaultTitles[type]}</h4>
)}
<div className={'text-sm text-neutral-700 leading-relaxed mt-0 prose prose-sm max-w-none'}>{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export {GuideLayout} from './GuideLayout';
export {InfoBox} from './InfoBox';
+3
View File
@@ -1,2 +1,5 @@
export * from './Navbar';
export * from './Footer';
export * from './ComparisonTable';
export * from './FAQSection';
export * from './CodeBlock';
@@ -0,0 +1,150 @@
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
interface EmailVerificationResultProps {
result: VerificationResult;
}
export function EmailVerificationResult({result}: EmailVerificationResultProps) {
return (
<div className="space-y-6">
{/* Overall Status */}
<div
className={`rounded-lg border-2 p-6 ${
result.valid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}
>
<div className="flex items-center gap-3">
{result.valid ? (
<CheckCircle className="h-8 w-8 text-green-600" />
) : (
<XCircle className="h-8 w-8 text-red-600" />
)}
<div>
<h3 className={`text-xl font-semibold ${result.valid ? 'text-green-900' : 'text-red-900'}`}>
{result.valid ? 'Valid Email' : 'Invalid Email'}
</h3>
<p className={`text-sm ${result.valid ? 'text-green-700' : 'text-red-700'}`}>{result.email}</p>
</div>
</div>
</div>
{/* Detailed Checks */}
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="border-b border-neutral-200 bg-neutral-50 px-6 py-4">
<h4 className="font-semibold text-neutral-900">Verification Details</h4>
</div>
<div className="divide-y divide-neutral-200">
{/* Domain Exists */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Domain Exists</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
</div>
</div>
{result.domainExists ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<XCircle className="h-5 w-5 text-red-600" />
)}
</div>
{/* MX Records */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">MX Records</p>
<p className="text-sm text-neutral-600">Mail server configured</p>
</div>
</div>
{result.hasMxRecords ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<XCircle className="h-5 w-5 text-red-600" />
)}
</div>
{/* Disposable Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Trash2 className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Disposable Email</p>
<p className="text-sm text-neutral-600">Temporary email service</p>
</div>
</div>
{result.isDisposable ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Typo Detection */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Typo Check</p>
<p className="text-sm text-neutral-600">Common spelling errors</p>
</div>
</div>
{result.isTypo ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Plus Addressing */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Plus Addressing</p>
<p className="text-sm text-neutral-600">Uses + tag (user+tag@domain.com)</p>
</div>
</div>
{result.isPlusAddressed ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<span className="text-sm text-neutral-500">No</span>
)}
</div>
</div>
</div>
{/* Suggested Email (if typo detected) */}
{result.suggestedEmail && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div>
<p className="font-medium text-yellow-900">Did you mean?</p>
<p className="text-sm text-yellow-700 mt-1">
<span className="font-mono bg-yellow-100 px-2 py-0.5 rounded">{result.suggestedEmail}</span>
</p>
</div>
</div>
</div>
)}
{/* Reasons */}
{result.reasons && result.reasons.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h4 className="font-semibold text-neutral-900 mb-3">Analysis</h4>
<ul className="space-y-2">
{result.reasons.map((reason, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-neutral-700 list-disc list-inside">
<span>{reason}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
import {EditorContent, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {TextAlign} from '@tiptap/extension-text-align';
import {Color} from '@tiptap/extension-color';
import {TextStyle} from '@tiptap/extension-text-style';
import {Link} from '@tiptap/extension-link';
import {Underline} from '@tiptap/extension-underline';
import {Image} from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import {MarkdownEmailToolbar} from './MarkdownEmailToolbar';
import {useEffect} from 'react';
interface MarkdownEmailEditorProps {
value: string;
onChange: (value: string) => void;
}
export function MarkdownEmailEditor({value, onChange}: MarkdownEmailEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
}),
Color,
TextStyle,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: 'noopener noreferrer',
},
}),
Image.configure({
HTMLAttributes: {
class: 'email-image',
},
inline: false,
}),
Placeholder.configure({
placeholder: 'Start typing your email here...',
}),
],
content: value || '',
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[500px] px-4 py-3 text-neutral-900',
},
},
onUpdate: ({editor}) => {
const html = editor.getHTML();
onChange(html);
},
});
// Update editor content when value prop changes from outside
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
return (
<div className="border border-neutral-200 rounded-lg bg-white">
<MarkdownEmailToolbar editor={editor} />
<div className="overflow-hidden">
<EditorContent editor={editor} className="bg-white" />
</div>
</div>
);
}
@@ -0,0 +1,492 @@
import {type Editor} from '@tiptap/react';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Image as ImageIcon,
Italic,
Link,
List,
ListOrdered,
Palette,
Quote,
Redo,
Strikethrough,
Underline as UnderlineIcon,
Undo,
} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {useCallback, useState} from 'react';
import {EDITOR_COLOR_GROUPS} from '../../lib/editorColors';
interface ToolbarProps {
editor: Editor | null;
}
export function MarkdownEmailToolbar({editor}: ToolbarProps) {
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showColorPicker, setShowColorPicker] = useState(false);
const [selectedColor, setSelectedColor] = useState('#000000');
const [customColor, setCustomColor] = useState('');
const [showImageInput, setShowImageInput] = useState(false);
const [imageUrl, setImageUrl] = useState('');
// Factory function to create editor command handlers
const createCommandHandler = useCallback(
(command: (editor: Editor) => void) => () => {
if (!editor) return;
command(editor);
},
[editor],
);
// Complex handlers that need state management
const addLink = useCallback(() => {
if (!editor || !linkUrl) return;
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run();
} else {
editor.chain().focus().setLink({href: linkUrl}).run();
}
setLinkUrl('');
setShowLinkInput(false);
}, [editor, linkUrl]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().unsetLink().run();
setLinkUrl('');
setShowLinkInput(false);
}, [editor]);
const setColor = useCallback(
(color: string) => {
if (!editor) return;
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
},
[editor],
);
const applyCustomColor = useCallback(() => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
setColor(customColor);
setCustomColor('');
setShowColorPicker(false);
}
}, [customColor, setColor]);
const toggleLinkInput = useCallback(() => {
if (!editor) return;
if (editor.isActive('link')) {
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}, [editor, showLinkInput]);
const addImage = useCallback(() => {
if (!editor || !imageUrl) return;
editor.chain().focus().setImage({src: imageUrl}).run();
setImageUrl('');
setShowImageInput(false);
}, [editor, imageUrl]);
if (!editor) {
return null;
}
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-40">
{/* History */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().undo().run())}
disabled={!editor.can().undo()}
className="h-8 w-8"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().redo().run())}
disabled={!editor.can().redo()}
className="h-8 w-8"
>
<Redo className="h-4 w-4" />
</Button>
</div>
{/* Text formatting */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBold().run())}
data-active={editor.isActive('bold')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleItalic().run())}
data-active={editor.isActive('italic')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleUnderline().run())}
data-active={editor.isActive('underline')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleStrike().run())}
data-active={editor.isActive('strike')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleCode().run())}
data-active={editor.isActive('code')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Code className="h-4 w-4" />
</Button>
</div>
{/* Headings */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 1}).run())}
data-active={editor.isActive('heading', {level: 1})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 2}).run())}
data-active={editor.isActive('heading', {level: 2})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 3}).run())}
data-active={editor.isActive('heading', {level: 3})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading3 className="h-4 w-4" />
</Button>
</div>
{/* Lists */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBulletList().run())}
data-active={editor.isActive('bulletList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleOrderedList().run())}
data-active={editor.isActive('orderedList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBlockquote().run())}
data-active={editor.isActive('blockquote')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Quote className="h-4 w-4" />
</Button>
</div>
{/* Alignment */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('left').run())}
data-active={editor.isActive({textAlign: 'left'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('center').run())}
data-active={editor.isActive({textAlign: 'center'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('right').run())}
data-active={editor.isActive({textAlign: 'right'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('justify').run())}
data-active={editor.isActive({textAlign: 'justify'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignJustify className="h-4 w-4" />
</Button>
</div>
{/* Color picker */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowColorPicker(!showColorPicker)}
className="h-8 w-8"
>
<Palette className="h-4 w-4" />
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
<div className="mb-3 pb-3 border-b border-neutral-200">
<label className="text-xs font-medium text-neutral-600 mb-1 block">Custom Color</label>
<div className="flex gap-2">
<Input
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value.toUpperCase())}
placeholder="#000000"
className="h-8 text-xs font-mono"
maxLength={7}
onKeyDown={e => {
if (e.key === 'Enter') {
applyCustomColor();
}
}}
/>
<Button
type="button"
size="sm"
onMouseDown={e => e.preventDefault()}
onClick={applyCustomColor}
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
className="h-8"
>
Apply
</Button>
</div>
</div>
{/* Color palette */}
<div className="space-y-3">
{EDITOR_COLOR_GROUPS.map(group => (
<div key={group.name}>
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
<div className="grid grid-cols-7 gap-1.5">
{group.colors.map(color => (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => {
setColor(color);
setShowColorPicker(false);
}}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-105 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Link */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={toggleLinkInput}
data-active={editor.isActive('link')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addLink();
} else if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addLink}>
{editor.isActive('link') ? 'Update' : 'Add'}
</Button>
</div>
{editor.isActive('link') && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="destructive"
onMouseDown={e => e.preventDefault()}
onClick={removeLink}
>
Remove Link
</Button>
</div>
)}
</div>
)}
</div>
{/* Image */}
<div className="relative">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowImageInput(!showImageInput)}
className="h-8 w-8"
>
<ImageIcon className="h-4 w-4" />
</Button>
{showImageInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="flex gap-2">
<input
type="url"
value={imageUrl}
onChange={e => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addImage();
} else if (e.key === 'Escape') {
setShowImageInput(false);
setImageUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addImage}>
Add
</Button>
</div>
</div>
)}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Color palette for the email editor toolbar
* Organized by hue using Tailwind color values
*/
export const EDITOR_COLOR_GROUPS = [
{
name: 'Neutrals',
colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'],
},
{
name: 'Reds',
colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'],
},
{
name: 'Oranges',
colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'],
},
{
name: 'Yellows',
colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'],
},
{
name: 'Greens',
colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'],
},
{
name: 'Blues',
colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'],
},
{
name: 'Purples',
colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'],
},
{
name: 'Pinks',
colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'],
},
] as const;
+170
View File
@@ -0,0 +1,170 @@
import juice from 'juice';
/**
* Converts modern HTML from Tiptap to email-friendly HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Preserves variable placeholders like {{email}}
*/
export function convertToEmailHtml(html: string): string {
// Wrap in email-safe container with basic styling
const wrappedHtml = `
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #374151;
margin: 0;
padding: 0;
}
h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px 0;
color: #111827;
}
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
color: #111827;
}
h3 {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
color: #111827;
}
p {
margin: 0 0 16px 0;
}
a {
color: #3B82F6;
text-decoration: underline;
}
ul, ol {
margin: 0 0 16px 0;
padding-left: 24px;
}
li {
margin-bottom: 8px;
}
blockquote {
margin: 0 0 16px 0;
padding-left: 16px;
border-left: 4px solid #E5E7EB;
color: #6B7280;
}
code {
background-color: #F3F4F6;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px 0;
}
th, td {
border: 1px solid #E5E7EB;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #F3F4F6;
font-weight: 600;
}
.variable-placeholder {
display: inline;
background-color: #DBEAFE;
color: #1E40AF;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3B82F6;
color: #FFFFFF;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 8px 0;
}
</style>
</head>
<body>
${html}
</body>
</html>
`;
// Inline CSS using juice
const inlined = juice(wrappedHtml, {
preserveMediaQueries: false,
preserveFontFaces: false,
removeStyleTags: true,
applyStyleTags: true,
});
// Extract just the body content
const bodyMatch = inlined.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined;
// Clean up Tiptap-specific artifacts
const cleaned = bodyContent
.replace(/\sdata-pm-slice="[^"]*"/g, '')
.replace(/\sclass=""/g, '')
.replace(/\sstyle=""/g, '');
return cleaned;
}
/**
* Wraps email fragment HTML in a complete HTML document structure
* This is what people expect when they want to send an email
*/
export function wrapEmailHtml(bodyContent: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email</title>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
/**
* Converts modern HTML from Tiptap to a complete, ready-to-send email HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Wraps in complete HTML document structure
* - Preserves variable placeholders like {{email}}
*/
export function convertToCompleteEmailHtml(html: string): string {
const fragment = convertToEmailHtml(html);
return wrapEmailHtml(fragment);
}
+26
View File
@@ -0,0 +1,26 @@
export interface EmailVerificationResult {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
const response = await fetch('/api/verify-email', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Verification failed');
}
return response.json();
}
+37
View File
@@ -0,0 +1,37 @@
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
/**
* Educational content for the email verification tool
*/
export const EMAIL_VERIFICATION_FEATURES = [
{
title: 'Improve Deliverability',
description: 'Remove invalid emails before sending to reduce bounce rates and improve email deliverability.',
icon: Mail,
},
{
title: 'Catch Typos',
description: 'Detect common typos like "gmial.com" and suggest corrections to capture valid addresses.',
icon: Search,
},
{
title: 'Protect Reputation',
description: 'High bounce rates hurt your sender reputation. Verify emails to maintain a good standing.',
icon: Shield,
},
{
title: 'DNS Validation',
description: 'Check if the email domain exists and has properly configured MX records for receiving mail.',
icon: CheckCircle,
},
{
title: 'Disposable Detection',
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
icon: Mail,
},
{
title: 'Plus Addressing',
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
icon: Shield,
},
] as const;
@@ -0,0 +1,70 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import {UtilitySchemas} from '@plunk/shared';
import {API_URI} from '../../lib/constants';
interface VerifyEmailResponse {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
interface ErrorResponse {
error: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
) {
// Only allow POST requests
if (req.method !== 'POST') {
return res.status(405).json({error: 'Method not allowed'});
}
// Validate input using Zod schema
const result = UtilitySchemas.email.safeParse(req.body);
if (!result.success) {
return res.status(400).json({error: 'Invalid email format'});
}
const {email} = result.data;
// Get secret key from environment
const secretKey = process.env.PLUNK_API_KEY;
if (!secretKey) {
console.error('PLUNK_API_KEY is not configured');
return res.status(500).json({error: 'Service configuration error'});
}
try {
// Call internal Plunk API with secret key from env
const response = await fetch(`${API_URI}/v1/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${secretKey}`,
},
body: JSON.stringify({email}),
});
const data = await response.json();
if (!response.ok) {
console.error('Plunk API error:', data);
return res.status(response.status).json({error: data.error?.message || 'Verification failed'});
}
// Return the data field from the Plunk API response
return res.status(200).json(data.data);
} catch (error) {
console.error('Error verifying email:', error);
return res.status(500).json({error: 'Internal server error'});
}
}
@@ -0,0 +1,623 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
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."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-api-guide"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email APIs allow developers to programmatically send, receive, and manage emails from applications. Whether
you're sending order confirmations, password resets, or marketing campaigns, email APIs provide a reliable,
scalable way to integrate email into your software.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
This guide covers everything you need to know: how email APIs work, implementation examples in multiple
languages, best practices, and choosing the right provider.
</p>
</section>
{/* What is an Email API */}
<section id="what-is-email-api" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is an Email API?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
An email API is a programmatic interface that lets you send and manage emails via HTTP requests instead of
manually configuring SMTP servers. APIs abstract away the complexity of email delivery, providing simple HTTP
endpoints to send emails and webhooks to track delivery status.
</p>
<div className="grid gap-6 md:grid-cols-2 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">SMTP (Traditional)</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Direct protocol for sending email</li>
<li> Requires managing connections</li>
<li> Manual error handling</li>
<li> Limited delivery tracking</li>
<li> More complex implementation</li>
<li> Port 25, 587, or 465</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Email API (Modern)</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> HTTP-based RESTful interface</li>
<li> No connection management needed</li>
<li> Structured error responses</li>
<li> Built-in tracking & analytics</li>
<li> Simpler to implement</li>
<li> Standard HTTP/HTTPS</li>
</ul>
</div>
</div>
</div>
<InfoBox type="tip" title="When to Use Email APIs">
<p>
Email APIs are ideal for transactional emails (order confirmations, password resets), automated
notifications, and programmatic campaigns. If you're building software that sends emails, APIs are almost
always the better choice over SMTP.
</p>
</InfoBox>
</section>
{/* How Email APIs Work */}
<section id="how-it-works" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Email APIs Work</h2>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Authentication</h3>
<p className="text-neutral-700">
You authenticate requests using an API key (usually passed in headers). This identifies your account and
authorizes API access.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Make HTTP Request</h3>
<p className="text-neutral-700">
Send a POST request to the API endpoint with email details (recipient, subject, body, etc.) as JSON.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. API Validates & Queues</h3>
<p className="text-neutral-700">
The API validates your request, queues the email for delivery, and returns a response with the email ID
and status.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Delivery</h3>
<p className="text-neutral-700">
The service handles SMTP connections, retry logic, and delivery to the recipient's mail server.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Webhooks & Tracking</h3>
<p className="text-neutral-700">
You receive webhook notifications for delivery events (delivered, bounced, opened, clicked) and can query
the API for email status.
</p>
</div>
</div>
</section>
{/* Code Examples */}
<section id="examples" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email API Code Examples</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Here's how to send an email using Plunk's API in various languages:
</p>
<div className="space-y-8">
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">JavaScript / Node.js</h3>
<CodeBlock
language="javascript"
title="Node.js Example"
code={`// Using fetch (Node.js 18+ or with node-fetch)
const response = await fetch('https://api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
body: JSON.stringify({
to: '[email protected]',
subject: 'Welcome to our platform!',
body: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
// Optional fields
from: '[email protected]',
name: 'Your Company',
replyTo: '[email protected]'
})
});
const data = await response.json();
if (response.ok) {
console.log('Email sent!', data.emailId);
} else {
console.error('Failed to send:', data.error);
}`}
/>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Python</h3>
<CodeBlock
language="python"
title="Python Example"
code={`import requests
response = requests.post(
'https://api.useplunk.com/v1/send',
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
},
json={
'to': '[email protected]',
'subject': 'Welcome to our platform!',
'body': '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
'from': '[email protected]',
'name': 'Your Company'
}
)
if response.status_code == 200:
data = response.json()
print(f"Email sent! ID: {data['emailId']}")
else:
print(f"Error: {response.json()['error']}")`}
/>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">PHP</h3>
<CodeBlock
language="php"
title="PHP Example"
code={`<?php
$ch = curl_init('https://api.useplunk.com/v1/send');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer YOUR_API_KEY'
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'to' => '[email protected]',
'subject' => 'Welcome to our platform!',
'body' => '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
'from' => '[email protected]',
'name' => 'Your Company'
]));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($httpCode === 200) {
echo "Email sent! ID: " . $data['emailId'];
} else {
echo "Error: " . $data['error'];
}
?>`}
/>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Ruby</h3>
<CodeBlock
language="ruby"
title="Ruby Example"
code={`require 'net/http'
require 'json'
uri = URI('https://api.useplunk.com/v1/send')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri.path)
request['Content-Type'] = 'application/json'
request['Authorization'] = 'Bearer YOUR_API_KEY'
request.body = {
to: '[email protected]',
subject: 'Welcome to our platform!',
body: '<h1>Welcome!</h1><p>Thanks for signing up.</p>',
from: '[email protected]',
name: 'Your Company'
}.to_json
response = http.request(request)
data = JSON.parse(response.body)
if response.code.to_i == 200
puts "Email sent! ID: #{data['emailId']}"
else
puts "Error: #{data['error']}"
end`}
/>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Go</h3>
<CodeBlock
language="go"
title="Go Example"
code={`package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
type EmailRequest struct {
To string \`json:"to"\`
Subject string \`json:"subject"\`
Body string \`json:"body"\`
From string \`json:"from"\`
Name string \`json:"name"\`
}
func main() {
email := EmailRequest{
To: "[email protected]",
Subject: "Welcome to our platform!",
Body: "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
From: "[email protected]",
Name: "Your Company",
}
jsonData, _ := json.Marshal(email)
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 200 {
var result map[string]interface{}
json.Unmarshal(body, &result)
fmt.Printf("Email sent! ID: %v\\n", result["emailId"])
} else {
fmt.Println("Error:", string(body))
}
}`}
/>
</div>
</div>
</section>
{/* Common Features */}
<section id="features" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common Email API Features</h2>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Sending Features</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Send individual or batch emails</li>
<li> HTML and plain text support</li>
<li> Attachments</li>
<li> CC, BCC recipients</li>
<li> Custom headers</li>
<li> Template rendering</li>
<li> Scheduled sending</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Tracking & Analytics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Delivery status tracking</li>
<li> Open tracking</li>
<li> Click tracking</li>
<li> Bounce detection</li>
<li> Spam complaint monitoring</li>
<li> Unsubscribe management</li>
<li> Real-time analytics</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Webhooks & Events</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Delivery notifications</li>
<li> Bounce notifications</li>
<li> Spam complaint alerts</li>
<li> Unsubscribe events</li>
<li> Open and click events</li>
<li> Custom event triggers</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Management Features</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Suppression list management</li>
<li> Contact management</li>
<li> Domain verification</li>
<li> Template management</li>
<li> API key management</li>
<li> Rate limiting</li>
</ul>
</div>
</div>
</div>
</section>
{/* Best Practices */}
<section id="best-practices" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email API Best Practices</h2>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Secure Your API Keys</h3>
<p className="text-neutral-700">
Store API keys in environment variables, never in code. Use separate keys for development, staging, and
production. Rotate keys periodically and immediately if compromised.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Implement Proper Error Handling</h3>
<p className="text-neutral-700 mb-3">Handle different HTTP status codes appropriately:</p>
<ul className="list-disc list-inside space-y-1 text-sm text-neutral-700">
<li>200: Success</li>
<li>400-499: Client errors (bad request, validation failed) - don't retry</li>
<li>500-599: Server errors - retry with exponential backoff</li>
<li>429: Rate limit exceeded - back off and retry later</li>
</ul>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Use Webhooks for Delivery Status</h3>
<p className="text-neutral-700">
Don't poll the API for email status. Set up webhooks to receive real-time delivery notifications
(delivered, bounced, opened, clicked). This is more efficient and provides faster updates.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Respect Rate Limits</h3>
<p className="text-neutral-700">
Implement rate limiting in your code to stay within API limits. Queue emails and send in batches. Use
exponential backoff when you receive 429 rate limit errors.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Validate Email Addresses</h3>
<p className="text-neutral-700">
Validate email format before making API calls. Check for common typos. Consider using email validation
APIs to verify deliverability before sending.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Use Templates</h3>
<p className="text-neutral-700">
Store email templates in your email service provider rather than hardcoding HTML in your application.
This allows non-developers to update email content without code changes.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Monitor Deliverability Metrics</h3>
<p className="text-neutral-700">
Track bounce rates, spam complaints, and engagement metrics. Set up alerts for anomalies. Address
deliverability issues proactively.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
8
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Test in Sandbox/Development Mode</h3>
<p className="text-neutral-700">
Use sandbox or test mode during development. Test error scenarios, retry logic, and webhook handling
before deploying to production.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
9
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Log API Requests & Responses</h3>
<p className="text-neutral-700">
Log all API interactions (but redact sensitive data like API keys). This helps debug issues and
understand email sending patterns.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
10
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Handle Timeouts</h3>
<p className="text-neutral-700">
Set appropriate timeouts for API requests (typically 10-30 seconds). Don't block user requests waiting
for email API responsesqueue emails asynchronously if needed.
</p>
</div>
</div>
</div>
</section>
{/* Choosing a Provider */}
<section id="choosing-provider" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Choosing an Email API Provider</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Consider these factors when selecting an email API provider:
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Deliverability Reputation</h3>
<p className="text-neutral-700">
Choose providers with strong deliverability rates and sender reputation. Poor deliverability means your
emails land in spam, defeating the purpose.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Feature Set</h3>
<p className="text-neutral-700">
Ensure the API supports your needs: templates, webhooks, analytics, scheduling, attachments, etc. Some
providers specialize in transactional emails, others in marketing.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Pricing Model</h3>
<p className="text-neutral-700">
Understand pricing: per-email charges, monthly tiers, overage fees. Calculate costs for your expected
volume. Watch for hidden fees and price increases at higher volumes.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Developer Experience</h3>
<p className="text-neutral-700">
Good documentation, SDKs in your language, clear error messages, and responsive support make
implementation much easier.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Scalability & Reliability</h3>
<p className="text-neutral-700">
Can the provider handle your peak volumes? What's their uptime guarantee (SLA)? Do they have redundancy
and failover systems?
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Compliance & Security</h3>
<p className="text-neutral-700">
Ensure the provider complies with GDPR, CAN-SPAM, and other relevant regulations. Check their security
certifications (SOC 2, ISO 27001).
</p>
</div>
</div>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/transactional-vs-marketing-email"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Transactional vs Marketing Email</h3>
<p className="text-sm text-neutral-600">Understand email types for APIs.</p>
</div>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Ensure API-sent emails reach the inbox.</p>
</div>
</Link>
<Link
href="/guides/email-sender-reputation"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Sender Reputation</h3>
<p className="text-sm text-neutral-600">Maintain good sender reputation.</p>
</div>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,433 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
export default function EmailBounceRate() {
return (
<GuideLayout
title="Email Bounce Rate: Hard vs Soft Bounces & How to Reduce Them"
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/email-bounce-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email bounce rate measures the percentage of emails that couldn't be delivered to recipients' inboxes. Bounces
occur for various reasonssome temporary, others permanentand high bounce rates seriously damage your sender
reputation and deliverability.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
Understanding the types of bounces and how to minimize them is essential for maintaining a healthy email
program.
</p>
</section>
{/* What is Bounce Rate */}
<section id="what-is-bounce-rate" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Email Bounce Rate?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Bounce rate is the percentage of sent emails that were rejected by receiving mail servers and couldn't be
delivered.
</p>
<div className="rounded-xl border-2 border-neutral-900 bg-neutral-50 p-8 mb-8">
<div className="text-center">
<div className="text-3xl font-bold text-neutral-900 mb-4">Bounce Rate Formula</div>
<CodeBlock
language="text"
code={`Bounce Rate = (Bounced Emails ÷ Sent Emails) × 100
Example:
- Sent: 10,000 emails
- Bounced: 150 emails
Bounce Rate = (150 ÷ 10,000) × 100 = 1.5%`}
showCopy={false}
/>
</div>
</div>
<div className="grid gap-6 md:grid-cols-3 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-green-600 mb-2">&lt;2%</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Excellent</h3>
<p className="text-sm text-neutral-700">Healthy list with good hygiene practices</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-amber-600 mb-2">2-5%</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Concerning</h3>
<p className="text-sm text-neutral-700">List quality issues need attention</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-red-600 mb-2">&gt;5%</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Critical</h3>
<p className="text-sm text-neutral-700">Serious problems damaging sender reputation</p>
</div>
</div>
</section>
{/* Types of Bounces */}
<section id="types-of-bounces" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Hard Bounces vs Soft Bounces</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Not all bounces are the same. Understanding the difference between hard and soft bounces is crucial for
managing your email list.
</p>
<div className="grid gap-8 md:grid-cols-2">
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<h3 className="text-2xl font-semibold text-red-900 mb-4">Hard Bounces (Permanent)</h3>
<p className="text-neutral-700 mb-4">
Hard bounces occur when an email can't be delivered for permanent reasons. These addresses will never
receive your emails.
</p>
<div className="space-y-3 mb-4">
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Common Causes:</h4>
<ul className="space-y-1 text-sm text-neutral-700">
<li> Email address doesn't exist</li>
<li> Domain name doesn't exist</li>
<li> Email server has completely blocked delivery</li>
<li> Invalid email address format</li>
</ul>
</div>
</div>
<div className="rounded-lg bg-red-100 border border-red-200 p-4">
<p className="text-sm font-semibold text-red-900 mb-2">Action Required:</p>
<p className="text-sm text-neutral-700">
<strong>Remove immediately.</strong> Never send to hard bounce addresses again. Continuing to send to
them damages your sender reputation.
</p>
</div>
</div>
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<h3 className="text-2xl font-semibold text-amber-900 mb-4">Soft Bounces (Temporary)</h3>
<p className="text-neutral-700 mb-4">
Soft bounces are temporary delivery failures. The email address is valid, but delivery failed for a
temporary reason.
</p>
<div className="space-y-3 mb-4">
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Common Causes:</h4>
<ul className="space-y-1 text-sm text-neutral-700">
<li> Recipient's mailbox is full</li>
<li> Email server is temporarily down or busy</li>
<li> Email message is too large</li>
<li> Recipient's server is experiencing issues</li>
</ul>
</div>
</div>
<div className="rounded-lg bg-amber-100 border border-amber-200 p-4">
<p className="text-sm font-semibold text-amber-900 mb-2">Action Required:</p>
<p className="text-sm text-neutral-700">
<strong>Retry automatically.</strong> Most platforms retry for 24-72 hours. After 3-5 consecutive soft
bounces, treat as a hard bounce and remove.
</p>
</div>
</div>
</div>
<InfoBox type="info" title="Bounce vs Block" className="mt-6">
<p>
A <strong>block</strong> occurs when the receiving server actively rejects your email due to reputation or
content issues. Unlike bounces (address problems), blocks indicate deliverability problems that affect all
your emails to that domain.
</p>
</InfoBox>
</section>
{/* Common Bounce Reasons */}
<section id="bounce-reasons" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common Bounce Reasons & Error Codes</h2>
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full">
<thead>
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold">Error Type</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Reason</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Type</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">550</td>
<td className="px-6 py-4 text-sm text-neutral-700">Mailbox unavailable</td>
<td className="px-6 py-4 text-sm text-red-600">Hard</td>
<td className="px-6 py-4 text-sm text-neutral-700">Remove</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">551</td>
<td className="px-6 py-4 text-sm text-neutral-700">User not local/Invalid address</td>
<td className="px-6 py-4 text-sm text-red-600">Hard</td>
<td className="px-6 py-4 text-sm text-neutral-700">Remove</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">552</td>
<td className="px-6 py-4 text-sm text-neutral-700">Mailbox full</td>
<td className="px-6 py-4 text-sm text-amber-600">Soft</td>
<td className="px-6 py-4 text-sm text-neutral-700">Retry</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">553</td>
<td className="px-6 py-4 text-sm text-neutral-700">Mailbox name invalid</td>
<td className="px-6 py-4 text-sm text-red-600">Hard</td>
<td className="px-6 py-4 text-sm text-neutral-700">Remove</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">554</td>
<td className="px-6 py-4 text-sm text-neutral-700">Transaction failed</td>
<td className="px-6 py-4 text-sm text-neutral-600">Varies</td>
<td className="px-6 py-4 text-sm text-neutral-700">Investigate</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">421</td>
<td className="px-6 py-4 text-sm text-neutral-700">Service not available</td>
<td className="px-6 py-4 text-sm text-amber-600">Soft</td>
<td className="px-6 py-4 text-sm text-neutral-700">Retry</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">450</td>
<td className="px-6 py-4 text-sm text-neutral-700">Mailbox busy</td>
<td className="px-6 py-4 text-sm text-amber-600">Soft</td>
<td className="px-6 py-4 text-sm text-neutral-700">Retry</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* How to Reduce Bounces */}
<section id="reduce-bounces" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Reduce Email Bounce Rates</h2>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Use Double Opt-In</h3>
<p className="text-neutral-700">
Require new subscribers to confirm their email address by clicking a verification link. This ensures the
address is valid, active, and belongs to the person who submitted it. Double opt-in reduces bounce rates
by 50-70%.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Validate Email Addresses</h3>
<p className="text-neutral-700 mb-3">
Use real-time email validation on signup forms to catch typos and invalid formats. Validate:
</p>
<ul className="list-disc list-inside space-y-1 text-sm text-neutral-700">
<li>Email format (contains @ and valid domain)</li>
<li>Domain has valid MX records</li>
<li>Catch common typos (gmial.com gmail.com)</li>
<li>Block disposable email addresses (if needed)</li>
</ul>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Remove Hard Bounces Immediately</h3>
<p className="text-neutral-700">
Automate removal of hard bounce addresses from your list. Never send to them again. Continued sending to
invalid addresses signals poor list hygiene to email providers.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Clean Your List Regularly</h3>
<p className="text-neutral-700">
Remove or re-engage inactive subscribers every 6-12 months. Email addresses become invalid over time
(15- 20% per year). Regular list cleaning prevents bounce accumulation.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Never Buy Email Lists</h3>
<p className="text-neutral-700">
Purchased lists have 20-40% invalid addresses and zero engagement. They'll destroy your bounce rate and
sender reputation. Build your list organicallyit's slower but dramatically more effective.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Monitor Soft Bounces</h3>
<p className="text-neutral-700">
Track addresses that repeatedly soft bounce. After 3-5 consecutive soft bounces over multiple campaigns,
treat them as hard bounces and remove them.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Optimize Email Size</h3>
<p className="text-neutral-700">
Keep email size under 100KB to avoid soft bounces from size limits. Compress images, minimize code, and
avoid large attachments (link to downloads instead).
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
8
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Maintain Consistent Sending</h3>
<p className="text-neutral-700">
Send regularly to maintain list freshness. Long gaps between sends (3+ months) increase bounce rates as
addresses become invalid or subscribers forget about you.
</p>
</div>
</div>
</div>
</section>
{/* Impact on Deliverability */}
<section id="impact" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Bounce Rate Affects Deliverability</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Bounce rates directly impact your sender reputation and ability to reach the inbox:
</p>
<div className="space-y-6">
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<h3 className="text-xl font-semibold text-red-900 mb-3">Damaged Sender Reputation</h3>
<p className="text-neutral-700">
Email providers track bounce rates. High rates signal poor list quality and careless email practices,
damaging your reputation score. This causes more emailseven to valid addressesto land in spam.
</p>
</div>
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<h3 className="text-xl font-semibold text-amber-900 mb-3">Spam Trap Hits</h3>
<p className="text-neutral-700">
Invalid addresses can be recycled as spam trapsaddresses used to catch senders with poor list hygiene.
Sending to spam traps can get you blacklisted, severely impacting deliverability across all recipients.
</p>
</div>
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<h3 className="text-xl font-semibold text-blue-900 mb-3">IP/Domain Blacklisting</h3>
<p className="text-neutral-700">
Consistently high bounce rates can lead to your sending IP or domain being blacklisted by email providers
or third-party blacklist services, making it extremely difficult to deliver emails.
</p>
</div>
<div className="rounded-xl border-2 border-purple-200 bg-purple-50 p-6">
<h3 className="text-xl font-semibold text-purple-900 mb-3">Reduced Engagement</h3>
<p className="text-neutral-700">
High bounce rates correlate with low engagement. Email providers notice when large portions of your list
aren't valid, reducing trust in your remaining emails.
</p>
</div>
</div>
<InfoBox type="warning" title="Recovery Takes Time" className="mt-6">
<p>
Once your sender reputation is damaged by high bounce rates, recovery can take weeks or months of
consistently good sending behavior. Prevention is far easier than recovery.
</p>
</InfoBox>
</section>
{/* Monitoring Bounces */}
<section id="monitoring" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring & Managing Bounces</h2>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">What to Track</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Overall bounce rate (target: &lt;2%)</li>
<li> Hard bounce rate specifically</li>
<li> Soft bounce rate and retry success</li>
<li> Bounce rate by campaign</li>
<li> Bounce rate trends over time</li>
<li> Specific bounce reasons/error codes</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">Automated Actions</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Auto-remove hard bounces immediately</li>
<li> Retry soft bounces 3-5 times over 72 hours</li>
<li> Remove persistent soft bouncers</li>
<li> Alert when bounce rate exceeds threshold</li>
<li> Weekly bounce rate reports</li>
<li> Validate emails at signup</li>
</ul>
</div>
</div>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Complete guide to reaching the inbox.</p>
</Link>
<Link
href="/guides/email-sender-reputation"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Sender Reputation</h3>
<p className="text-sm text-neutral-600">Build and maintain sender reputation.</p>
</Link>
<Link
href="/guides/email-marketing-best-practices"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Marketing Best Practices</h3>
<p className="text-sm text-neutral-600">Complete email marketing guide.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,474 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
export default function EmailClickThroughRate() {
return (
<GuideLayout
title="Email Click-Through Rate: How to Optimize CTAs & Increase Clicks"
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
lastUpdated="2025-12-20"
readTime="10 min"
canonical="https://www.useplunk.com/guides/email-click-through-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email click-through rate (CTR) measures the percentage of recipients who clicked a link in your email. While
open rates show subject line effectiveness, CTR reveals how compelling your email content and calls-to-action
are.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
High CTR indicates engaged subscribers who find value in your emails and are moving toward conversion. This
guide covers everything you need to optimize CTAs and maximize clicks.
</p>
</section>
{/* What is CTR */}
<section id="what-is-ctr" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Email Click-Through Rate?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Click-through rate is the percentage of delivered emails that received at least one click on a link.
</p>
<div className="rounded-xl border-2 border-neutral-900 bg-neutral-50 p-8 mb-8">
<div className="text-center mb-6">
<div className="text-3xl font-bold text-neutral-900 mb-4">CTR Formula</div>
<CodeBlock
language="text"
code={`CTR = (Unique Clicks ÷ Delivered Emails) × 100
Example:
- Sent: 10,000 emails
- Delivered: 9,800 emails
- Unique Clicks: 294
CTR = (294 ÷ 9,800) × 100 = 3.0%`}
showCopy={false}
/>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-neutral-900 mb-4">CTOR Formula</div>
<CodeBlock
language="text"
code={`CTOR (Click-to-Open Rate) = (Unique Clicks ÷ Unique Opens) × 100
Example:
- Unique Opens: 2,450
- Unique Clicks: 294
CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
showCopy={false}
/>
</div>
</div>
<InfoBox type="info" title="CTR vs CTOR">
<p>
<strong>CTR</strong> measures overall campaign effectiveness (including deliverability and opens).{' '}
<strong>CTOR</strong> isolates content quality by measuring engagement from those who opened. Use bothCTR
for overall performance, CTOR for content optimization.
</p>
</InfoBox>
</section>
{/* Benchmarks */}
<section id="benchmarks" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">CTR Benchmarks by Industry</h2>
<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">Industry</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Average CTR</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Good CTR</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Average CTOR</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">2-3%</td>
<td className="px-6 py-4 text-sm text-neutral-700">4%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">10-15%</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">E-commerce / Retail</td>
<td className="px-6 py-4 text-sm text-neutral-700">3-5%</td>
<td className="px-6 py-4 text-sm text-neutral-700">6%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">12-18%</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Media / Publishing</td>
<td className="px-6 py-4 text-sm text-neutral-700">4-6%</td>
<td className="px-6 py-4 text-sm text-neutral-700">7%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">15-20%</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">2-4%</td>
<td className="px-6 py-4 text-sm text-neutral-700">5%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">10-14%</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Non-Profit</td>
<td className="px-6 py-4 text-sm text-neutral-700">2-4%</td>
<td className="px-6 py-4 text-sm text-neutral-700">5%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">10-16%</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Education</td>
<td className="px-6 py-4 text-sm text-neutral-700">3-5%</td>
<td className="px-6 py-4 text-sm text-neutral-700">6%+</td>
<td className="px-6 py-4 text-sm text-neutral-700">12-17%</td>
</tr>
</tbody>
</table>
</div>
<InfoBox type="tip" title="Context Matters">
<p>
CTR varies dramatically by email type: promotional emails (2-3%), newsletters (4-6%), transactional emails
(10-15%), and triggered emails (15-30%). Compare similar email types, not all campaigns together.
</p>
</InfoBox>
</section>
{/* Factors Affecting CTR */}
<section id="factors" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Click-Through Rates?</h2>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Call-to-Action (CTA) Design</h3>
<p className="text-neutral-700">
Your CTA's design, placement, and copy directly impact clicks. Clear, prominent, action-oriented CTAs
significantly outperform generic "Click here" links.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Relevance</h3>
<p className="text-neutral-700">
Targeted, personalized content generates 2-3x higher CTR than generic blasts. Segmentation and
personalization ensure emails match subscriber interests.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Value Proposition</h3>
<p className="text-neutral-700">
Recipients need a clear reason to click. Compelling value propositionsexclusive content, limited offers,
solutions to problemsdrive clicks.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Mobile Optimization</h3>
<p className="text-neutral-700">
60%+ of emails are read on mobile. Unoptimized emails with small links or poorly formatted content see 50%
lower CTR on mobile devices.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Content Scannability</h3>
<p className="text-neutral-700">
Most people skim emails. Clear structure with headers, bullet points, and whitespace helps readers find
and click CTAs quickly.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Number of CTAs</h3>
<p className="text-neutral-700">
More CTAs = divided attention. Emails with one primary CTA convert 371% better than those with multiple
competing CTAs.
</p>
</div>
</div>
</section>
{/* How to Improve CTR */}
<section id="improve-ctr" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Increase Email Click-Through Rates</h2>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Use Clear, Action-Oriented CTA Copy</h3>
<p className="text-neutral-700 mb-3">
Be specific about what happens when users click. Action verbs + value = higher CTR.
</p>
<div className="rounded-lg bg-neutral-100 p-4 space-y-2">
<div className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Download your free guide"</span>
</div>
<div className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Start your 14-day free trial"</span>
</div>
<div className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Get 20% off today only"</span>
</div>
<div className="flex items-start gap-2">
<span className="text-red-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Click here"</span>
</div>
<div className="flex items-start gap-2">
<span className="text-red-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Learn more"</span>
</div>
</div>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Design Prominent CTA Buttons</h3>
<p className="text-neutral-700 mb-3">Button best practices:</p>
<ul className="list-disc list-inside space-y-1 text-sm text-neutral-700">
<li>Use contrasting colors that stand out from email design</li>
<li>Make buttons large enough to tap on mobile (44x44px minimum)</li>
<li>Add white space around buttons for visual prominence</li>
<li>Use button text, not images (better for accessibility and loading)</li>
<li>Repeat primary CTA if email is long (top and bottom)</li>
</ul>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Create a Single, Clear Focus</h3>
<p className="text-neutral-700">
Each email should have one primary goal. Multiple CTAs competing for attention reduce overall clicks.
Guide readers toward one clear action.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Personalize Content</h3>
<p className="text-neutral-700">
Go beyond "Hi [Name]". Use behavioral data, purchase history, browsing activity, or preferences to send
highly relevant emails. Personalized CTAs see 202% higher CTR.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Optimize for Mobile</h3>
<p className="text-neutral-700 mb-3">Mobile optimization is critical:</p>
<ul className="list-disc list-inside space-y-1 text-sm text-neutral-700">
<li>Responsive design that adapts to screen size</li>
<li>Large, tappable buttons (not small text links)</li>
<li>Single-column layout for easy scrolling</li>
<li>Concise copy (mobile users scan quickly)</li>
<li>Place CTAs above the fold when possible</li>
</ul>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Use Urgency and Scarcity (Authentically)</h3>
<p className="text-neutral-700">
Genuine limited-time offers or limited quantity create urgency that drives clicks. Be authenticfalse
urgency damages trust. "Sale ends tonight" or "Only 5 spots remaining" work when true.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Segment Your Audience</h3>
<p className="text-neutral-700">
Send targeted emails to specific groups based on behavior, interests, or demographics. Segmented
campaigns see 3x higher CTR than non-segmented blasts.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
8
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Include Preview/Teaser Content</h3>
<p className="text-neutral-700">
Show just enough value to create curiosity. Product images, stat highlights, or content snippets
encourage clicks to see more. "Read the full article" works better than just a headline.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
9
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">A/B Test CTAs</h3>
<p className="text-neutral-700 mb-3">Test systematically:</p>
<ul className="list-disc list-inside space-y-1 text-sm text-neutral-700">
<li>Button copy ("Get started" vs "Start free trial")</li>
<li>Button color (brand color vs high-contrast color)</li>
<li>Button placement (top, middle, bottom)</li>
<li>Number of CTAs (one vs multiple)</li>
<li>Button size and shape</li>
</ul>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
10
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Improve Email-Landing Page Match</h3>
<p className="text-neutral-700">
Ensure landing pages match email promises. Mismatched expectations cause immediate bounces. Email says
"20% off"? Landing page should show 20% off, not a generic homepage.
</p>
</div>
</div>
</div>
</section>
{/* CTA Placement */}
<section id="cta-placement" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">CTA Placement Best Practices</h2>
<div className="grid gap-6 md:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Short Emails (&lt;200 words)</h3>
<p className="text-sm text-neutral-700 mb-3">One CTA in the middle or at the end.</p>
<div className="rounded-lg bg-neutral-50 p-3 text-xs text-neutral-600">Intro Value prop CTA</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Medium Emails (200-500 words)</h3>
<p className="text-sm text-neutral-700 mb-3">CTAs at the beginning and end.</p>
<div className="rounded-lg bg-neutral-50 p-3 text-xs text-neutral-600">
Intro + CTA Content Final CTA
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Long Emails (&gt;500 words)</h3>
<p className="text-sm text-neutral-700 mb-3">Multiple CTAs throughout.</p>
<div className="rounded-lg bg-neutral-50 p-3 text-xs text-neutral-600">
CTA Content CTA More content Final CTA
</div>
</div>
</div>
<InfoBox type="tip" title="Above the Fold" className="mt-6">
<p>
Place at least one CTA above the fold (visible without scrolling) on mobile. Many readers won't scroll, so
give them an early opportunity to click.
</p>
</InfoBox>
</section>
{/* Common Mistakes */}
<section id="common-mistakes" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common CTR Mistakes to Avoid</h2>
<div className="space-y-6">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Too Many CTAs</h3>
<p className="text-neutral-700">
Multiple competing CTAs confuse readers and reduce overall clicks. Focus on one primary action per email.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Vague CTA Copy</h3>
<p className="text-neutral-700">
"Click here" and "Learn more" don't communicate value. Be specific: "Download free guide" or "Start 14-day
trial" tell readers exactly what they'll get.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Image-Only CTAs</h3>
<p className="text-neutral-700">
Many email clients block images by default. If your CTA is an image, users won't see it. Use HTML buttons
with text.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Small, Hard-to-Tap Links</h3>
<p className="text-neutral-700">
Tiny text links are difficult to tap on mobile. Use large buttons (minimum 44x44px) for easy tapping.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> No Clear Value Proposition</h3>
<p className="text-neutral-700">
Readers won't click if they don't know why they should. Clearly communicate the benefit of clicking before
asking them to act.
</p>
</div>
</div>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-open-rate"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Open Rates</h3>
<p className="text-sm text-neutral-600">Improve opens to get more clicks.</p>
</Link>
<Link
href="/guides/email-marketing-best-practices"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Marketing Best Practices</h3>
<p className="text-sm text-neutral-600">Complete email marketing guide.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Reach the inbox for better engagement.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,380 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import Link from 'next/link';
export default function EmailDeliverability() {
return (
<GuideLayout
title="Email Deliverability Guide: Reach the Inbox Every Time"
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-deliverability"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email deliverability is the measure of how successfully your emails reach recipients' inboxes rather than spam
folders or being blocked entirely. Even perfectly crafted emails are worthless if they never reach your
audience.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
This comprehensive guide covers everything you need to know about email deliverabilityfrom technical
authentication to content best practicesso you can maximize inbox placement rates.
</p>
</section>
{/* What is Email Deliverability */}
<section id="what-is-deliverability" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Email Deliverability?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Email deliverability refers to your ability to deliver emails to subscribers' inboxes. It's measured as a
percentage of sent emails that successfully reach the inbox.
</p>
<div className="grid gap-6 md:grid-cols-3 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-green-600 mb-2">95%+</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Excellent</h3>
<p className="text-sm text-neutral-700">
Strong sender reputation, proper authentication, engaged audience
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-amber-600 mb-2">85-94%</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Good</h3>
<p className="text-sm text-neutral-700">Room for improvement in authentication or engagement</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-red-600 mb-2">&lt;85%</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Poor</h3>
<p className="text-sm text-neutral-700">Serious issues requiring immediate attention</p>
</div>
</div>
<InfoBox type="info" title="Deliverability vs Delivery Rate">
<p>
<strong>Delivery rate</strong> measures emails that weren't bounced (reached the server).{' '}
<strong>Deliverability</strong> measures emails that reached the inbox specifically, not spam. You want both
to be high.
</p>
</InfoBox>
</section>
{/* Key Factors */}
<section id="key-factors" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Key Factors Affecting Deliverability</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Email deliverability depends on multiple interconnected factors:
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Sender Reputation</h3>
<p className="text-neutral-700">
Email providers track your sending behavior over time. High engagement rates, low spam complaints, and few
bounces build a positive reputation. Poor practices damage it.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Authentication</h3>
<p className="text-neutral-700">
SPF, DKIM, and DMARC authenticate your emails and prove they're legitimate. Without proper authentication,
emails are more likely to be flagged as spam.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Email Content</h3>
<p className="text-neutral-700">
Spam filters analyze your subject lines, body content, links, and images. Spammy language, excessive
links, or misleading content trigger filters.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Engagement</h3>
<p className="text-neutral-700">
Sending to engaged subscribers who want your emails is crucial. High open rates and clicks signal quality.
Low engagement or spam complaints hurt deliverability.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Technical Infrastructure</h3>
<p className="text-neutral-700">
Your sending IP address, domain reputation, and email infrastructure affect how providers perceive your
emails. Dedicated IPs and proper warm-up are important at scale.
</p>
</div>
</div>
</section>
{/* Best Practices */}
<section id="best-practices" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Deliverability Best Practices</h2>
<div className="space-y-8">
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Authentication & Technical Setup</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Configure SPF, DKIM, and DMARC</h4>
<p className="text-neutral-700">
Implement all three authentication protocols. This proves your emails are legitimate and protects
your domain from spoofing.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Use a Dedicated Sending Domain</h4>
<p className="text-neutral-700">
Send marketing emails from a subdomain (e.g., mail.yourdomain.com) to protect your main domain's
reputation if issues arise.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Warm Up New IPs and Domains</h4>
<p className="text-neutral-700">
If using a new IP or domain, gradually increase sending volume over 2-4 weeks. Sudden high volume
from new sources looks suspicious.
</p>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">List Management</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Use Double Opt-In</h4>
<p className="text-neutral-700">
Require subscribers to confirm their email address. This ensures list quality and prevents spam trap
addresses from being added.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Clean Your List Regularly</h4>
<p className="text-neutral-700">
Remove hard bounces immediately and consider removing subscribers who haven't engaged in 6-12
months. Inactive subscribers hurt your reputation.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Make Unsubscribing Easy</h4>
<p className="text-neutral-700">
Include a clear unsubscribe link in every email. Making it hard to unsubscribe leads to spam
complaints, which are much worse for deliverability.
</p>
</div>
</div>
</div>
</div>
<div>
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Content Best Practices</h3>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Avoid Spam Trigger Words</h4>
<p className="text-neutral-700 mb-3">
Words like "FREE", "ACT NOW", "LIMITED TIME", excessive exclamation marks, and all caps trigger spam
filters. Write naturally and professionally.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
8
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Balance Text and Images</h4>
<p className="text-neutral-700">
Don't send image-only emails. Maintain a healthy text-to-image ratio (at least 60% text). Include
alt text for images.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
9
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Use Relevant Subject Lines</h4>
<p className="text-neutral-700">
Subject lines should accurately reflect email content. Misleading subjects increase spam complaints
and damage trust.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Common Issues */}
<section id="common-issues" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common Deliverability Issues & Solutions</h2>
<div className="space-y-6">
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<h3 className="text-xl font-semibold text-red-900 mb-3">Problem: Emails Going to Spam</h3>
<p className="text-neutral-700 mb-3">
<strong>Causes:</strong> Poor sender reputation, missing authentication, spammy content, low engagement
</p>
<p className="text-neutral-700">
<strong>Solutions:</strong> Verify SPF/DKIM/DMARC setup, improve email content, segment your list to send
to engaged subscribers, warm up sending reputation
</p>
</div>
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<h3 className="text-xl font-semibold text-amber-900 mb-3">Problem: High Bounce Rate</h3>
<p className="text-neutral-700 mb-3">
<strong>Causes:</strong> Invalid email addresses, purchased lists, poor list hygiene
</p>
<p className="text-neutral-700">
<strong>Solutions:</strong> Use double opt-in, validate email addresses, remove hard bounces immediately,
regularly clean your list
</p>
</div>
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<h3 className="text-xl font-semibold text-blue-900 mb-3">Problem: Low Engagement Rates</h3>
<p className="text-neutral-700 mb-3">
<strong>Causes:</strong> Irrelevant content, wrong send frequency, stale email list
</p>
<p className="text-neutral-700">
<strong>Solutions:</strong> Segment your audience, personalize content, test send times, re-engagement
campaigns for inactive subscribers
</p>
</div>
<div className="rounded-xl border-2 border-purple-200 bg-purple-50 p-6">
<h3 className="text-xl font-semibold text-purple-900 mb-3">Problem: Blacklisted IP or Domain</h3>
<p className="text-neutral-700 mb-3">
<strong>Causes:</strong> Spam complaints, sending to spam traps, compromised account
</p>
<p className="text-neutral-700">
<strong>Solutions:</strong> Check blacklists (MXToolbox, Spamhaus), request delisting, fix underlying
issues, consider a new sending domain if severely damaged
</p>
</div>
</div>
</section>
{/* Monitoring */}
<section id="monitoring" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Track these metrics to stay on top of deliverability:
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Delivery Rate</h3>
<p className="text-neutral-700">
Percentage of emails that didn't bounce. Target: 98%+. Track hard vs soft bounces separately.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Inbox Placement Rate</h3>
<p className="text-neutral-700">
Percentage of delivered emails that reached the inbox (not spam). Target: 95%+. Use seed list testing.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Spam Complaint Rate</h3>
<p className="text-neutral-700">
Percentage of recipients who mark as spam. Target: &lt;0.1%. Even 0.3% is concerning.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Engagement Metrics</h3>
<p className="text-neutral-700">
Open rates, click rates, and unsubscribe rates. Sustained low engagement signals poor deliverability.
</p>
</div>
</div>
<InfoBox type="tip" title="Use DMARC Reports">
<p>
DMARC aggregate reports show authentication success rates and potential spoofing attempts. Review these
weekly to catch deliverability issues early.
</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 Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/what-is-dkim"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DKIM?</h3>
<p className="text-sm text-neutral-600">Learn about DKIM email authentication.</p>
</Link>
<Link
href="/guides/what-is-spf"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is SPF?</h3>
<p className="text-sm text-neutral-600">Understand SPF records and sender authorization.</p>
</Link>
<Link
href="/guides/email-sender-reputation"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Sender Reputation</h3>
<p className="text-sm text-neutral-600">Build and maintain sender reputation.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,549 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import Link from 'next/link';
export default function EmailMarketingBestPractices() {
return (
<GuideLayout
title="Email Marketing Best Practices: The Complete Guide"
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
lastUpdated="2025-12-20"
readTime="15 min"
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email marketing remains one of the most effective digital marketing channels, delivering an average ROI of
$36-42 for every $1 spent. But success requires following best practices that have evolved with technology,
regulations, and user expectations.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
This comprehensive guide covers everything you need to know: from building your list to crafting compelling
content, optimizing deliverability, and staying compliant with regulations.
</p>
</section>
{/* List Building */}
<section id="list-building" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Building Your Email List</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
A high-quality email list is the foundation of successful email marketing. Focus on engaged subscribers who
genuinely want to hear from you.
</p>
<div className="space-y-6">
<div className="rounded-xl border-2 border-green-200 bg-green-50 p-6">
<h3 className="text-xl font-semibold text-green-900 mb-3"> Best Practices</h3>
<ul className="space-y-3 text-neutral-700">
<li className="flex items-start gap-3">
<span className="text-green-600 font-bold mt-1"></span>
<span>
<strong>Use double opt-in:</strong> Require subscribers to confirm their email address. This ensures
list quality and reduces spam complaints.
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-600 font-bold mt-1"></span>
<span>
<strong>Offer clear value:</strong> Explain what subscribers will receive and how often. "Get weekly
tips on [topic]" is better than "Subscribe to our newsletter."
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-600 font-bold mt-1"></span>
<span>
<strong>Use multiple touchpoints:</strong> Add signup forms on your website, blog, checkout page,
social media, and in-person events.
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-600 font-bold mt-1"></span>
<span>
<strong>Provide incentives:</strong> Lead magnets like ebooks, discounts, templates, or exclusive
content encourage signups.
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-green-600 font-bold mt-1"></span>
<span>
<strong>Make forms easy:</strong> Keep signup forms simpleask only for essential information (usually
just email, sometimes name).
</span>
</li>
</ul>
</div>
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<h3 className="text-xl font-semibold text-red-900 mb-3"> Avoid These Mistakes</h3>
<ul className="space-y-3 text-neutral-700">
<li className="flex items-start gap-3">
<span className="text-red-600 font-bold mt-1"></span>
<span>
<strong>Never buy email lists:</strong> Purchased lists lead to spam complaints, poor engagement, and
damaged reputation.
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-red-600 font-bold mt-1"></span>
<span>
<strong>Don't use pre-checked boxes:</strong> Subscribers must actively opt-in. Pre-checked boxes
violate GDPR and reduce engagement.
</span>
</li>
<li className="flex items-start gap-3">
<span className="text-red-600 font-bold mt-1"></span>
<span>
<strong>Avoid hidden signup terms:</strong> Be transparent about email frequency and content type.
</span>
</li>
</ul>
</div>
</div>
</section>
{/* Content Best Practices */}
<section id="content" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Content Best Practices</h2>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Craft Compelling Subject Lines</h3>
<p className="text-neutral-700 mb-3">
Your subject line determines whether emails get opened. Keep them under 50 characters, create curiosity,
and be specific about value.
</p>
<div className="rounded-lg bg-neutral-50 p-4">
<p className="text-sm font-semibold text-neutral-900 mb-2">Examples:</p>
<ul className="space-y-1 text-sm text-neutral-700">
<li> "5 proven strategies to reduce churn by 30%"</li>
<li> "Your personalized year-end report is ready"</li>
<li> "Last chance: Sale ends tonight at midnight"</li>
<li> "Newsletter #47"</li>
<li> "Check this out!!!"</li>
</ul>
</div>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Personalize Beyond First Names</h3>
<p className="text-neutral-700">
Use behavioral data, purchase history, browsing activity, or preferences. Personalized emails deliver 6x
higher transaction rates than generic blasts.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Focus on One Primary Goal</h3>
<p className="text-neutral-700">
Each email should have one clear call-to-action (CTA). Multiple CTAs confuse readers and reduce conversion
rates. Guide recipients toward a single action.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Write Scannable Content</h3>
<p className="text-neutral-700 mb-3">Most people skim emails. Structure content for easy scanning:</p>
<ul className="list-disc list-inside space-y-1 text-neutral-700">
<li>Use short paragraphs (2-3 sentences max)</li>
<li>Include bullet points and numbered lists</li>
<li>Add descriptive subheadings</li>
<li>Use white space generously</li>
<li>Highlight key points with bold text</li>
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Create Compelling CTAs</h3>
<p className="text-neutral-700 mb-3">Effective CTAs are:</p>
<ul className="list-disc list-inside space-y-1 text-neutral-700">
<li>
<strong>Action-oriented:</strong> "Download the guide" not "Click here"
</li>
<li>
<strong>Specific:</strong> "Start your 14-day free trial" not "Get started"
</li>
<li>
<strong>Visually prominent:</strong> Button format with contrasting colors
</li>
<li>
<strong>Urgent when appropriate:</strong> "Claim your discount today"
</li>
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Provide Real Value</h3>
<p className="text-neutral-700">
Every email should benefit the reader. Share insights, solve problems, offer exclusive content, or provide
entertainment. Don't just promoteeducate and engage.
</p>
</div>
</div>
</section>
{/* Design Best Practices */}
<section id="design" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Design Best Practices</h2>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Mobile-First Design</h3>
<p className="text-neutral-700">
60%+ of emails are opened on mobile. Use responsive templates, large tap targets (44x44px minimum), and
test on multiple devices and email clients.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Optimize Images</h3>
<p className="text-neutral-700">
Compress images for fast loading, use alt text (many clients block images), and maintain 60/40
text-to-image ratio. Never send image-only emails.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Brand Consistency</h3>
<p className="text-neutral-700">
Use your brand colors, fonts, logo, and voice consistently. Recipients should instantly recognize your
emails. Create a template system for efficiency.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Accessible Design</h3>
<p className="text-neutral-700">
Use sufficient color contrast (4.5:1 minimum), readable font sizes (14px+ for body text), descriptive
link text, and semantic HTML for screen readers.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Test Across Email Clients</h3>
<p className="text-neutral-700">
Email rendering varies dramatically across Gmail, Outlook, Apple Mail, etc. Test your designs across
major clients before sending to your full list.
</p>
</div>
</div>
</div>
</section>
{/* Timing & Frequency */}
<section id="timing" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Timing & Frequency Best Practices</h2>
<div className="grid gap-6 md:grid-cols-2 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Best Send Times (General)</h3>
<ul className="space-y-2 text-neutral-700">
<li>
<strong>B2B:</strong> Tuesday-Thursday, 10am-11am or 2pm-3pm
</li>
<li>
<strong>B2C:</strong> Varies widelytest evenings and weekends
</li>
<li>
<strong>Avoid:</strong> Monday mornings (inbox overload) and Friday afternoons
</li>
</ul>
<InfoBox type="info" title="Test Your Audience" className="mt-4">
<p className="text-sm">
These are starting points. Your specific audience may behave differently. A/B test send times and let
data guide your strategy.
</p>
</InfoBox>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Email Frequency Guidelines</h3>
<ul className="space-y-2 text-neutral-700">
<li>
<strong>Weekly:</strong> Most common for newsletters and content
</li>
<li>
<strong>2-3x/week:</strong> Works for engaged audiences with fresh content
</li>
<li>
<strong>Daily:</strong> Only for news, deals, or highly engaged communities
</li>
<li>
<strong>Monthly:</strong> Risk of being forgotten; better for curated content
</li>
</ul>
<InfoBox type="warning" title="Monitor Unsubscribes">
<p className="text-sm">
If unsubscribe rates spike when you increase frequency, you're sending too often. Find the balance.
</p>
</InfoBox>
</div>
</div>
</section>
{/* Segmentation */}
<section id="segmentation" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Segmentation & Personalization</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Segmented campaigns generate 30% more opens and 50% more clicks than non-segmented campaigns. Send targeted
messages to specific groups rather than blasting your entire list.
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Common Segmentation Criteria</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Demographics:</strong> Age, location, job title, company size
</li>
<li>
<strong>Behavior:</strong> Purchase history, browsing activity, email engagement
</li>
<li>
<strong>Lifecycle stage:</strong> New subscriber, active customer, churned user
</li>
<li>
<strong>Engagement level:</strong> Highly engaged vs inactive subscribers
</li>
<li>
<strong>Preferences:</strong> Product interests, content topics, email frequency
</li>
<li>
<strong>Purchase recency:</strong> Recent buyers, lapsed customers
</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">Segment-Specific Campaigns</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Welcome series for new subscribers</li>
<li> Re-engagement campaigns for inactive users</li>
<li> Win-back campaigns for churned customers</li>
<li> Product recommendations based on past purchases</li>
<li> Location-specific offers and events</li>
<li> VIP campaigns for high-value customers</li>
</ul>
</div>
</div>
</section>
{/* Testing & Optimization */}
<section id="testing" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Testing & Optimization</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Continuous testing is essential for improving email performance. Never assumetest and let data guide
decisions.
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">What to A/B Test</h3>
<ul className="space-y-2 text-neutral-700">
<li>
<strong>Subject lines:</strong> Length, personalization, questions vs statements, emojis
</li>
<li>
<strong>Send times:</strong> Day of week, time of day, time zones
</li>
<li>
<strong>Content:</strong> Long vs short, text vs image-heavy, tone and style
</li>
<li>
<strong>CTAs:</strong> Button text, color, placement, number of CTAs
</li>
<li>
<strong>Sender name:</strong> Company name vs person's name
</li>
<li>
<strong>Personalization:</strong> Generic vs personalized content
</li>
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">A/B Testing Best Practices</h3>
<ul className="space-y-2 text-neutral-700">
<li> Test one variable at a time for clear results</li>
<li> Use statistically significant sample sizes (minimum 1,000+ per variant)</li>
<li> Run tests long enough to account for timing variations</li>
<li> Test with random splits, not cherry-picked segments</li>
<li> Implement winners, then test the next variable</li>
</ul>
</div>
</div>
</section>
{/* Compliance */}
<section id="compliance" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Legal Compliance</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Email marketing is heavily regulated. Non-compliance can result in massive fines and damage to your
reputation.
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<h3 className="text-xl font-semibold text-blue-900 mb-3">CAN-SPAM (US)</h3>
<ul className="space-y-2 text-neutral-700">
<li> Don't use misleading subject lines or headers</li>
<li> Include your physical mailing address</li>
<li> Provide a clear, easy unsubscribe mechanism</li>
<li> Honor unsubscribe requests within 10 business days</li>
<li> Identify the email as an advertisement (if applicable)</li>
</ul>
</div>
<div className="rounded-xl border-2 border-purple-200 bg-purple-50 p-6">
<h3 className="text-xl font-semibold text-purple-900 mb-3">GDPR (EU)</h3>
<ul className="space-y-2 text-neutral-700">
<li> Obtain explicit consent before sending marketing emails</li>
<li> Provide clear information about data usage</li>
<li> Allow subscribers to access, modify, or delete their data</li>
<li> Keep records of consent</li>
<li> Implement appropriate data security measures</li>
</ul>
</div>
</div>
<InfoBox type="warning" title="When in Doubt, Get Consent" className="mt-6">
<p>
Laws vary by jurisdiction. The safest approach is always to get explicit, documented consent before adding
anyone to your email list. Make unsubscribing easy and honor requests immediately.
</p>
</InfoBox>
</section>
{/* Metrics to Track */}
<section id="metrics" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Key Metrics to Track</h2>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Engagement Metrics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Open rate:</strong> Industry average: 15-25%
</li>
<li>
<strong>Click-through rate (CTR):</strong> Industry average: 2-5%
</li>
<li>
<strong>Click-to-open rate (CTOR):</strong> Clicks ÷ opens
</li>
<li>
<strong>Conversion rate:</strong> Goal completions ÷ delivered
</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">Health Metrics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Bounce rate:</strong> Target: &lt;2%
</li>
<li>
<strong>Unsubscribe rate:</strong> Target: &lt;0.5%
</li>
<li>
<strong>Spam complaint rate:</strong> Target: &lt;0.1%
</li>
<li>
<strong>List growth rate:</strong> New subscribers vs unsubscribes
</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">Business Metrics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>ROI:</strong> Revenue generated ÷ cost
</li>
<li>
<strong>Revenue per email:</strong> Total revenue ÷ emails sent
</li>
<li>
<strong>Customer lifetime value (CLV):</strong> Long-term value
</li>
<li>
<strong>Cost per acquisition (CPA):</strong> Acquisition cost
</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">Deliverability Metrics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Delivery rate:</strong> Target: 98%+
</li>
<li>
<strong>Inbox placement rate:</strong> Target: 95%+
</li>
<li>
<strong>Sender reputation score:</strong> Monitor regularly
</li>
<li>
<strong>Authentication status:</strong> SPF, DKIM, DMARC passing
</li>
</ul>
</div>
</div>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Ensure your emails reach the inbox.</p>
</Link>
<Link
href="/guides/email-open-rate"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Open Rates</h3>
<p className="text-sm text-neutral-600">Improve your open rates with proven tactics.</p>
</Link>
<Link
href="/guides/email-click-through-rate"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Click-Through Rate</h3>
<p className="text-sm text-neutral-600">Optimize CTAs for more clicks.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,394 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
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."
lastUpdated="2025-12-20"
readTime="10 min"
canonical="https://www.useplunk.com/guides/email-open-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email open rate measures the percentage of recipients who open your email. It's one of the most important
email marketing metrics, indicating how well your subject lines, sender name, and send timing resonate with
your audience.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
While recent privacy changes have affected open rate accuracy, it remains a valuable metric for understanding
email performance and audience engagement.
</p>
</section>
{/* What is Open Rate */}
<section id="what-is-open-rate" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Email Open Rate?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Open rate is calculated by dividing the number of unique opens by the number of delivered emails (sent emails
minus bounces).
</p>
<div className="rounded-xl border-2 border-neutral-900 bg-neutral-50 p-8 mb-8">
<div className="text-center">
<div className="text-3xl font-bold text-neutral-900 mb-4">Open Rate Formula</div>
<CodeBlock
language="text"
code={`Open Rate = (Unique Opens ÷ Emails Delivered) × 100
Example:
- Sent: 1,000 emails
- Bounced: 50 emails
- Delivered: 950 emails
- Unique Opens: 285
Open Rate = (285 ÷ 950) × 100 = 30%`}
showCopy={false}
/>
</div>
</div>
<InfoBox type="info" title="Unique vs Total Opens">
<p>
<strong>Unique opens</strong> count each recipient only once, even if they open multiple times.{' '}
<strong>Total opens</strong> count every open. Always use unique opens for open rate calculations to avoid
inflated metrics.
</p>
</InfoBox>
</section>
{/* Benchmarks */}
<section id="benchmarks" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Open Rate Benchmarks by Industry</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Open rates vary significantly by industry, audience, and email type:
</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">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">Good Open Rate</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">20-25%</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">E-commerce / Retail</td>
<td className="px-6 py-4 text-sm text-neutral-700">15-20%</td>
<td className="px-6 py-4 text-sm text-neutral-700">25%+</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">18-23%</td>
<td className="px-6 py-4 text-sm text-neutral-700">28%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Healthcare</td>
<td className="px-6 py-4 text-sm text-neutral-700">20-25%</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">Education</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">33%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Media / Publishing</td>
<td className="px-6 py-4 text-sm text-neutral-700">18-23%</td>
<td className="px-6 py-4 text-sm text-neutral-700">28%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Non-Profit</td>
<td className="px-6 py-4 text-sm text-neutral-700">24-30%</td>
<td className="px-6 py-4 text-sm text-neutral-700">35%+</td>
</tr>
</tbody>
</table>
</div>
<InfoBox type="warning" title="Post-Apple MPP Benchmarks">
<p>
Since Apple Mail Privacy Protection launched in 2021, industry-wide open rates have increased by 5-15
percentage points. Compare against your own historical data rather than relying solely on industry
benchmarks.
</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>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Subject Line</h3>
<p className="text-neutral-700 mb-3">
Your subject line is the #1 factor. It must be compelling, relevant, and create curiosity without being
clickbait. Personalization and specificity improve opens.
</p>
<div className="rounded-lg bg-neutral-50 p-4 space-y-2">
<div className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span className="text-sm text-neutral-700">
"John, your invoice is ready" (personal, specific, clear)
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-green-600 font-bold"></span>
<span className="text-sm text-neutral-700">
"3 proven ways to increase conversion rates" (specific value)
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-red-600 font-bold"></span>
<span className="text-sm text-neutral-700">"You won't believe this!" (clickbait, vague)</span>
</div>
<div className="flex items-start gap-2">
<span className="text-red-600 font-bold"></span>
<span className="text-sm text-neutral-700">"Newsletter #47" (generic, uninteresting)</span>
</div>
</div>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Sender Name</h3>
<p className="text-neutral-700">
Recipients decide whether to open based on who it's from. Use a recognizable nameeither your brand or a
person from your company. "Company Name" or "John from Company" work better than "[email protected]".
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Send Time & Day</h3>
<p className="text-neutral-700">
Timing impacts visibility. B2B emails typically perform best Tuesday-Thursday, 10am-2pm. B2C varies more
by audiencetest evenings and weekends. Avoid Monday mornings and Friday afternoons.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Segmentation</h3>
<p className="text-neutral-700">
Engaged subscribers open more. Segmenting by behavior, interests, or demographics ensures relevance.
Inactive subscribers drag down open ratesconsider re-engagement or removal.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Deliverability & Sender Reputation</h3>
<p className="text-neutral-700">
If emails land in spam, they won't be opened. Maintain good sender reputation through proper
authentication (SPF, DKIM, DMARC), low spam complaints, and high engagement.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Email Frequency</h3>
<p className="text-neutral-700">
Too frequent leads to fatigue and unsubscribes. Too infrequent and subscribers forget you. Find the sweet
spot for your audiencetypically 1-4 emails per week for marketing.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Mobile Optimization</h3>
<p className="text-neutral-700">
60%+ of emails are opened on mobile. Use short subject lines (40-50 characters), clear preview text, and
mobile-responsive design to improve mobile open rates.
</p>
</div>
</div>
</section>
{/* How to Improve */}
<section id="improve-open-rates" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Improve Email Open Rates</h2>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Write Compelling Subject Lines</h3>
<p className="text-neutral-700">
Test different approaches: questions, personalization, numbers, urgency (without being spammy). Keep
them under 50 characters. A/B test to find what resonates with your audience.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Personalize Beyond First Names</h3>
<p className="text-neutral-700">
Use behavioral data, past purchases, browsing history, or location. "Products you recently viewed" or
"Based on your interest in [topic]" outperform generic blasts.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Segment Your Audience</h3>
<p className="text-neutral-700">
Send targeted emails to specific groups based on engagement level, purchase history, demographics, or
interests. Segmented campaigns see 14-100% higher open rates than non-segmented.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Optimize Preview Text</h3>
<p className="text-neutral-700">
Preview text appears next to the subject line. Use it to complement your subject, add context, or create
additional curiosity. Don't let it default to "View this email in your browser".
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Clean Your Email List Regularly</h3>
<p className="text-neutral-700">
Remove subscribers who haven't engaged in 6-12 months. While this reduces list size, it dramatically
improves open rates and sender reputation. Quality over quantity.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Test Send Times</h3>
<p className="text-neutral-700">
Run A/B tests sending the same email at different times. Track when your specific audience is most
responsive. Send time optimization can improve opens by 20-50%.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Re-engage Inactive Subscribers</h3>
<p className="text-neutral-700">
Before removing inactive users, send a re-engagement campaign: "We miss you" or "Still interested?"
emails with special offers. Remove those who still don't engage.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
8
</div>
<div className="w-full max-w-full break-words">
<h3 className="font-semibold text-neutral-900 mb-2">Use a Recognizable Sender Name</h3>
<p className="text-neutral-700">
Build consistency with your sender name. People are more likely to open emails from names they
recognize. Avoid changing it frequently.
</p>
</div>
</div>
</div>
</section>
{/* Apple MPP Impact */}
<section id="apple-mpp" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Apple Mail Privacy Protection Impact</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Since iOS 15, Apple Mail Privacy Protection pre-loads email images and tracking pixels, making open tracking
less reliable for Apple Mail users.
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<h3 className="text-xl font-semibold text-amber-900 mb-3">What Changed</h3>
<ul className="space-y-2 text-neutral-700">
<li> Apple Mail pre-loads all images automatically</li>
<li> Opens are registered even if user never sees email</li>
<li> 30-50% of email lists use Apple Mail</li>
<li> Open rates appear 5-15% higher than reality</li>
<li> Open-based automation is less accurate</li>
</ul>
</div>
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<h3 className="text-xl font-semibold text-blue-900 mb-3">How to Adapt</h3>
<ul className="space-y-2 text-neutral-700">
<li> Focus on click rates as primary engagement metric</li>
<li> Track conversions and replies, not just opens</li>
<li> Use multi-touch attribution</li>
<li> Compare your own historical data, not industry averages</li>
<li> Segment by email client to understand true engagement</li>
</ul>
</div>
</div>
<InfoBox type="tip" title="Beyond Open Rates">
<p>
With less reliable open tracking, prioritize metrics like click-through rate, conversion rate, reply rate,
and revenue per email. These better indicate true engagement and ROI.
</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 Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-click-through-rate"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Click-Through Rate</h3>
<p className="text-sm text-neutral-600">Optimize CTAs and improve email clicks.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Reach the inbox to maximize opens.</p>
</Link>
<Link
href="/guides/email-marketing-best-practices"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Marketing Best Practices</h3>
<p className="text-sm text-neutral-600">Complete email marketing strategy guide.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,578 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
export default function EmailSenderReputation() {
return (
<GuideLayout
title="Email Sender Reputation: Build & Maintain Trust for Better Deliverability"
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
lastUpdated="2025-12-20"
readTime="11 min"
canonical="https://www.useplunk.com/guides/email-sender-reputation"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Email sender reputation is your sending domain and IP address's trustworthiness score in the eyes of email
providers. It's the single most important factor determining whether your emails reach the inbox or spam
folder.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
Think of it like a credit score for email: good reputation = inbox placement, bad reputation = spam folder or
blocked delivery. This guide explains how reputation works and how to build and maintain it.
</p>
</section>
{/* What is Sender Reputation */}
<section id="what-is-reputation" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Email Sender Reputation?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Sender reputation is a score (typically 0-100) that email providers (Gmail, Outlook, Yahoo) assign to your
sending domain and IP address. It's calculated based on your sending history, engagement rates, spam
complaints, authentication, and list quality.
</p>
<div className="grid gap-6 md:grid-cols-3 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-green-600 mb-2">80-100</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Excellent</h3>
<p className="text-sm text-neutral-700">
Strong inbox placement, trusted sender, high engagement, proper authentication
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-amber-600 mb-2">50-79</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Moderate</h3>
<p className="text-sm text-neutral-700">Some emails may reach spam, mixed signals, needs improvement</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="text-3xl font-bold text-red-600 mb-2">&lt;50</div>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Poor</h3>
<p className="text-sm text-neutral-700">Most emails go to spam or blocked, serious deliverability issues</p>
</div>
</div>
<InfoBox type="info" title="Domain vs IP Reputation">
<p>
Email providers track reputation for both your <strong>sending domain</strong> (yourdomain.com) and{' '}
<strong>IP address</strong>. Both matter. Domain reputation is more permanent, while IP reputation can be
changed by switching IPs (though you have to rebuild it).
</p>
</InfoBox>
</section>
{/* Factors Affecting Reputation */}
<section id="factors" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Sender Reputation?</h2>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Spam Complaint Rate</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> When recipients mark your emails as spam, it severely damages
reputation. Target: &lt;0.1% (1 complaint per 1,000 emails). Even 0.3% is concerning.
</p>
<p className="text-sm text-neutral-600">
Prevention: Only send to opted-in subscribers, provide value, make unsubscribing easy.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Engagement Metrics</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Very High.</strong> Email providers track opens, clicks, replies, forwards, and deletes.
High engagement signals that recipients want your emails. Low engagement suggests spam.
</p>
<p className="text-sm text-neutral-600">
Boost engagement: Send relevant, valuable content to interested subscribers.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Bounce Rate</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: High.</strong> High bounce rates (especially hard bounces) indicate poor list hygiene,
purchased lists, or spam traps. Target: &lt;2% overall bounce rate.
</p>
<p className="text-sm text-neutral-600">
Prevent bounces: Use double opt-in, validate emails, remove hard bounces immediately.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Authentication</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: High.</strong> Proper SPF, DKIM, and DMARC authentication proves your emails are
legitimate. Missing authentication raises red flags.
</p>
<p className="text-sm text-neutral-600">
Required: Implement all three authentication protocols (SPF, DKIM, DMARC).
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Spam Trap Hits</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> Spam traps are email addresses used to catch senders with poor
practices. Hitting spam traps can get you blacklisted instantly.
</p>
<p className="text-sm text-neutral-600">
Avoid: Never buy lists, use double opt-in, clean old addresses regularly.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Sending Volume & Consistency</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Moderate.</strong> Sudden spikes in volume look suspicious. Inconsistent sending (long
gaps, then huge blasts) damages reputation.
</p>
<p className="text-sm text-neutral-600">
Best practice: Send consistently and gradually increase volume over time (warm-up).
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Content Quality</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Moderate.</strong> Spammy content (excessive links, misleading subject lines, all caps)
triggers filters and reduces engagement.
</p>
<p className="text-sm text-neutral-600">
Write naturally: Professional, valuable content that matches subject line promises.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">8. Blacklist Status</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> Being listed on major blacklists (Spamhaus, Barracuda, SURBL) can block
delivery to entire domains.
</p>
<p className="text-sm text-neutral-600">
Monitor: Regularly check blacklist status and request delisting if listed.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">9. Sending History</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Cumulative.</strong> Reputation is built over time. New domains/IPs have no history (zero
reputation) and need to be warmed up gradually.
</p>
<p className="text-sm text-neutral-600">
For new senders: Start with small volumes and gradually increase over 4-8 weeks.
</p>
</div>
</div>
</section>
{/* Building Reputation */}
<section id="building" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Build Sender Reputation</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Building reputation from scratch (new domain or IP) requires patience and best practices:
</p>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Warm Up New IPs and Domains</h3>
<p className="text-neutral-700 mb-3">
Gradually increase sending volume over 4-8 weeks. Start with your most engaged subscribers.
</p>
<CodeBlock
language="text"
title="Example Warm-Up Schedule"
code={`Week 1: 200-500 emails/day
Week 2: 500-1,000 emails/day
Week 3: 1,000-5,000 emails/day
Week 4: 5,000-10,000 emails/day
Week 5: 10,000-20,000 emails/day
Week 6: 20,000-50,000 emails/day
Week 7: 50,000-100,000 emails/day
Week 8+: Full volume
Adjust based on engagement and bounce rates.`}
showCopy={false}
/>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Start with Highly Engaged Subscribers</h3>
<p className="text-neutral-700">
During warm-up, send only to subscribers who recently opened or clicked. High engagement in early sends
establishes positive reputation quickly.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Implement Full Authentication</h3>
<p className="text-neutral-700">
Set up SPF, DKIM, and DMARC before sending. Proper authentication is table stakes for building
reputation.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Maintain Consistent Sending</h3>
<p className="text-neutral-700">
Send regularly (daily or weekly) rather than sporadic blasts. Consistency builds trust with email
providers.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Use Double Opt-In</h3>
<p className="text-neutral-700">
Require email confirmation before adding subscribers. This ensures list quality and prevents spam trap
addresses from the start.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Monitor Metrics Closely</h3>
<p className="text-neutral-700">
Track bounce rate, spam complaints, and engagement during warm-up. If metrics degrade, slow down volume
increases and improve list quality.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
7
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Provide Immediate Value</h3>
<p className="text-neutral-700">
Your first emails set the tone. Deliver on promises made during signup. High engagement on early emails
accelerates reputation building.
</p>
</div>
</div>
</div>
</section>
{/* Maintaining Reputation */}
<section id="maintaining" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Maintaining Good Sender Reputation</h2>
<p className="text-neutral-700 leading-relaxed mb-6">Once built, reputation requires ongoing attention:</p>
<div className="space-y-6">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Clean Your List Regularly</h3>
<p className="text-neutral-700 mb-3">Remove or re-engage inactive subscribers every 6-12 months:</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Send re-engagement campaigns to inactive subscribers</li>
<li> Remove those who don't re-engage</li>
<li> Immediately remove hard bounces</li>
<li> Remove persistent soft bouncers (3-5 bounces)</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">Make Unsubscribing Easy</h3>
<p className="text-neutral-700 mb-3">
Paradoxically, easy unsubscribes protect reputation. Frustrated users who can't unsubscribe mark as spam
instead, which is far worse.
</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Include clear, one-click unsubscribe in every email</li>
<li> Process unsubscribes immediately (don't require confirmation)</li>
<li> Honor unsubscribes permanently</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">Monitor Reputation Metrics</h3>
<p className="text-neutral-700 mb-3">Track these indicators weekly:</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Delivery rate (target: 98%+)</li>
<li> Spam complaint rate (target: &lt;0.1%)</li>
<li> Bounce rate (target: &lt;2%)</li>
<li> Engagement rates (opens, clicks)</li>
<li> Blacklist status (monthly checks)</li>
<li> Sender Score (check quarterly)</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">Segment and Personalize</h3>
<p className="text-neutral-700">
Send targeted, relevant emails to specific subscriber groups. Segmentation dramatically improves
engagement, which protects reputation.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
Use Separate Domains for Different Email Types
</h3>
<p className="text-neutral-700 mb-3">
Protect critical transactional email reputation by separating from marketing:
</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Transactional: transact.yourdomain.com</li>
<li> Marketing: news.yourdomain.com or marketing.yourdomain.com</li>
<li> Benefits: Marketing issues won't affect mission-critical transactional emails</li>
</ul>
</div>
</div>
</section>
{/* Recovering Damaged Reputation */}
<section id="recovery" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Recovering from Damaged Reputation</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
If your reputation suffers, recovery is possible but takes time and discipline:
</p>
<div className="space-y-4">
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
1
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Identify the Root Cause</h3>
<p className="text-neutral-700">
Review recent campaigns for spam complaints, high bounces, poor engagement. Check if you're blacklisted.
Find and fix the underlying issue.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
2
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Clean Your List Aggressively</h3>
<p className="text-neutral-700">
Remove all unengaged subscribers, bounced addresses, and anyone who hasn't opened in 6+ months. Yes,
your list shrinks, but quality beats quantity for reputation recovery.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
3
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Reduce Volume Temporarily</h3>
<p className="text-neutral-700">
Cut sending volume by 50-70% while you fix issues. Send only to highly engaged subscribers. Rebuild
trust before scaling back up.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
4
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Request Blacklist Removal</h3>
<p className="text-neutral-700">
If blacklisted, identify which lists, fix the underlying issues, then request delisting. Most blacklists
have removal request processes. Be honest about what you fixed.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
5
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Send Valuable, Engaging Content</h3>
<p className="text-neutral-700">
Recovery requires proving you send emails people want. Focus on value, not promotions. High engagement
signals to providers that you've reformed.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-amber-50 border border-amber-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white text-sm font-bold">
6
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-amber-900 mb-2">Be Patient</h3>
<p className="text-neutral-700">
Reputation recovery typically takes 4-12 weeks of consistently good behavior. Monitor metrics closely
and don't rush back to high volumes before reputation improves.
</p>
</div>
</div>
</div>
<InfoBox type="warning" title="Severe Damage May Require Starting Fresh" className="mt-6">
<p>
If reputation is severely damaged (sender score &lt;30, multiple blacklistings, blocked by major providers),
recovery may be impractical. Consider migrating to a new subdomain or IP and starting reputation from
scratch. This is a last resort.
</p>
</InfoBox>
</section>
{/* Monitoring Tools */}
<section id="monitoring" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Reputation Monitoring Tools</h2>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Google Postmaster Tools</h3>
<p className="text-sm text-neutral-700 mb-3">
Free tool showing domain reputation, spam rate, authentication, and encryption for Gmail delivery.
</p>
<a
href="https://postmaster.google.com"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
postmaster.google.com
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Microsoft SNDS</h3>
<p className="text-sm text-neutral-700 mb-3">
Smart Network Data Services provides data on how Microsoft views your IPs, including spam complaints.
</p>
<a
href="https://sendersupport.olc.protection.outlook.com/snds/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Microsoft SNDS
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Sender Score (Validity)</h3>
<p className="text-sm text-neutral-700 mb-3">
Free sender reputation score (0-100) based on industry data. Widely used benchmark for IP reputation.
</p>
<a
href="https://www.validity.com/everest/senderscore/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Check Sender Score
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">MXToolbox Blacklist Check</h3>
<p className="text-sm text-neutral-700 mb-3">
Check if your domain or IP is on major blacklists. Monitor regularly to catch listings early.
</p>
<a
href="https://mxtoolbox.com/blacklists.aspx"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
MXToolbox
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Talos Intelligence</h3>
<p className="text-sm text-neutral-700 mb-3">
Cisco Talos provides sender reputation data. Check your IP's reputation and request delisting if needed.
</p>
<a
href="https://talosintelligence.com"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Talos Intelligence
</a>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">BarracudaCentral</h3>
<p className="text-sm text-neutral-700 mb-3">
Check reputation and blacklist status on Barracuda's network. Important for enterprise email delivery.
</p>
<a
href="https://www.barracudacentral.org/lookups"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
BarracudaCentral
</a>
</div>
</div>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Complete deliverability guide.</p>
</Link>
<Link
href="/guides/email-bounce-rate"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Bounce Rate</h3>
<p className="text-sm text-neutral-600">Reduce bounces to protect reputation.</p>
</Link>
<Link
href="/guides/what-is-dkim"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DKIM?</h3>
<p className="text-sm text-neutral-600">Email authentication for better reputation.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
+266
View File
@@ -0,0 +1,266 @@
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Book, Code, Mail, Shield, TrendingUp, Users} from 'lucide-react';
interface Guide {
title: string;
description: string;
href: string;
icon: React.ComponentType<{className?: string}>;
badge?: string;
}
const guides: Guide[] = [
{
title: 'What is DKIM?',
description: 'Learn how DKIM email authentication works and why it matters for deliverability.',
href: '/guides/what-is-dkim',
icon: Shield,
badge: 'Authentication',
},
{
title: 'What is SPF?',
description: 'Understand SPF records and how they protect your emails from spoofing.',
href: '/guides/what-is-spf',
icon: Shield,
badge: 'Authentication',
},
{
title: 'What is DMARC?',
description: 'Complete guide to DMARC policies and email security best practices.',
href: '/guides/what-is-dmarc',
icon: Shield,
badge: 'Authentication',
},
{
title: 'Email Deliverability Guide',
description: 'Improve your email deliverability with best practices and proven strategies.',
href: '/guides/email-deliverability',
icon: TrendingUp,
badge: 'Deliverability',
},
{
title: 'Email Open Rates',
description: 'Industry benchmarks and strategies to improve your email open rates.',
href: '/guides/email-open-rate',
icon: Mail,
badge: 'Analytics',
},
{
title: 'Email Marketing Best Practices',
description: 'Comprehensive guide to email marketing: timing, content, design, and more.',
href: '/guides/email-marketing-best-practices',
icon: Book,
badge: 'Best Practices',
},
{
title: 'Email Bounce Rate',
description: 'Understand hard vs soft bounces and how to reduce your bounce rate.',
href: '/guides/email-bounce-rate',
icon: TrendingUp,
badge: 'Deliverability',
},
{
title: 'Email Click-Through Rate',
description: 'Optimize your CTAs and improve email click-through rates.',
href: '/guides/email-click-through-rate',
icon: Mail,
badge: 'Analytics',
},
{
title: 'Transactional vs Marketing Email',
description: 'Understand the legal and technical differences between email types.',
href: '/guides/transactional-vs-marketing-email',
icon: Book,
badge: 'Fundamentals',
},
{
title: 'Email API Guide',
description: 'Everything you need to know about email APIs with code examples.',
href: '/guides/email-api-guide',
icon: Code,
badge: 'Technical',
},
{
title: 'Email Sender Reputation',
description: 'Build and maintain a positive sender reputation for better deliverability.',
href: '/guides/email-sender-reputation',
icon: Users,
badge: 'Deliverability',
},
];
/**
* Email Guides hub page
*/
export default function GuidesIndex() {
return (
<>
<NextSeo
title="Email Marketing & Deliverability Guides | Plunk"
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
canonical="https://www.useplunk.com/guides"
openGraph={{
title: 'Email Marketing & Deliverability Guides | Plunk',
description:
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
url: 'https://www.useplunk.com/guides',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<Book className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Email Guides</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Master email
<br />
marketing & deliverability
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Free guides on email authentication, deliverability, best practices, and technical implementation. Learn
from the experts.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</motion.div>
</section>
{/* Guides Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Browse all guides</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to master email</p>
</motion.div>
<div className={'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{guides.map((guide, index) => {
const Icon = guide.icon;
return (
<motion.div
key={guide.href}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
>
<Link
href={guide.href}
className={
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<div className={'flex items-start justify-between mb-4'}>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Icon className="h-5 w-5" />
</div>
{guide.badge && (
<span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700'}>
{guide.badge}
</span>
)}
</div>
<h3 className={'text-xl font-semibold text-neutral-900 mb-2 group-hover:text-neutral-700'}>
{guide.title}
</h3>
<p className={'text-sm text-neutral-600 leading-relaxed'}>{guide.description}</p>
</Link>
</motion.div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to get started?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Put these guides into practice with Plunk's modern email platform. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,555 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import Link from 'next/link';
export default function TransactionalVsMarketingEmail() {
return (
<GuideLayout
title="Transactional vs Marketing Email: Legal & Technical Differences"
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
lastUpdated="2025-12-20"
readTime="10 min"
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
Not all emails are created equal. Transactional and marketing emails serve different purposes, follow
different legal rules, and require different strategies. Understanding these differences is essential for
compliance, deliverability, and effective email communication.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
This guide explains the key distinctions, legal requirements, and best practices for each email type.
</p>
</section>
{/* Quick Comparison */}
<section id="comparison" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Quick Comparison</h2>
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full">
<thead>
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold">Aspect</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Transactional Email</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Marketing Email</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Purpose</td>
<td className="px-6 py-4 text-sm text-neutral-700">Facilitate transaction or provide service info</td>
<td className="px-6 py-4 text-sm text-neutral-700">Promote products, services, or content</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Trigger</td>
<td className="px-6 py-4 text-sm text-neutral-700">User action or system event</td>
<td className="px-6 py-4 text-sm text-neutral-700">Scheduled or campaign-based</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Consent Required</td>
<td className="px-6 py-4 text-sm text-neutral-700">No (expected as part of service)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (explicit opt-in required)</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Unsubscribe Link</td>
<td className="px-6 py-4 text-sm text-neutral-700">Not required</td>
<td className="px-6 py-4 text-sm text-neutral-700">Required by law</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Open Rate</td>
<td className="px-6 py-4 text-sm text-neutral-700">60-80% (very high)</td>
<td className="px-6 py-4 text-sm text-neutral-700">15-25% (average)</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-semibold text-neutral-900">Volume</td>
<td className="px-6 py-4 text-sm text-neutral-700">Lower, event-based</td>
<td className="px-6 py-4 text-sm text-neutral-700">Higher, scheduled campaigns</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Transactional Email */}
<section id="transactional" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Transactional Email?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Transactional emails are automated messages sent in response to a user's action or system event. They contain
information the user needs or expects to facilitate a transaction or use a service.
</p>
<div className="mb-8">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Common Types of Transactional Emails</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Account & Authentication</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Welcome emails after signup</li>
<li> Email verification/confirmation</li>
<li> Password reset requests</li>
<li> Login notifications</li>
<li> Two-factor authentication codes</li>
<li> Account deletion confirmations</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Commerce & Transactions</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Order confirmations</li>
<li> Shipping notifications</li>
<li> Delivery updates</li>
<li> Payment receipts</li>
<li> Refund confirmations</li>
<li> Invoice emails</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Notifications & Alerts</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Activity notifications</li>
<li> Comment or mention alerts</li>
<li> Security alerts</li>
<li> System status updates</li>
<li> Subscription renewals</li>
<li> Trial expiration notices</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Account Management</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Billing statements</li>
<li> Usage reports</li>
<li> Account updates</li>
<li> Plan changes</li>
<li> Subscription confirmations</li>
<li> Account deactivation notices</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Transactional Email Best Practices</h3>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Send Immediately</h4>
<p className="text-neutral-700">
Transactional emails should be sent within seconds or minutes of the triggering action. Users expect
instant confirmationdelays cause anxiety and support tickets.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Prioritize Clarity & Accuracy</h4>
<p className="text-neutral-700">
Include all critical information: order numbers, amounts, dates, tracking links. Be preciseerrors in
transactional emails damage trust significantly.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Use Descriptive Subject Lines</h4>
<p className="text-neutral-700">
Make subject lines scannable and specific: "Your order #12345 has shipped" not "Update from
YourCompany". Users should know what the email contains at a glance.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Minimize Promotional Content</h4>
<p className="text-neutral-700">
The primary content must be transactional. Small promotional sections (cross-sells, upsells) are
acceptable at the bottom, but don't turn order confirmations into marketing emails.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Ensure High Deliverability</h4>
<p className="text-neutral-700">
Use a dedicated sending infrastructure for transactional emails. They're mission-criticalusers depend
on them. Don't let marketing email issues affect transactional deliverability.
</p>
</div>
</div>
</div>
</section>
{/* Marketing Email */}
<section id="marketing" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What is Marketing Email?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Marketing emails are promotional messages sent to build relationships, drive engagement, and encourage
conversions. Recipients must explicitly opt-in to receive them.
</p>
<div className="mb-8">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Common Types of Marketing Emails</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Promotional Campaigns</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Product announcements</li>
<li> Sale and discount promotions</li>
<li> New feature launches</li>
<li> Seasonal campaigns</li>
<li> Limited-time offers</li>
<li> Flash sales</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Content Marketing</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Newsletters</li>
<li> Blog post roundups</li>
<li> Educational content</li>
<li> Industry news and trends</li>
<li> Case studies</li>
<li> Webinar invitations</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Nurture & Engagement</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Welcome series (beyond initial welcome)</li>
<li> Onboarding sequences</li>
<li> Re-engagement campaigns</li>
<li> Win-back emails</li>
<li> Customer surveys</li>
<li> Loyalty program updates</li>
</ul>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full break-words">
<h4 className="font-semibold text-neutral-900 mb-3">Lifecycle Marketing</h4>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Abandoned cart reminders</li>
<li> Product recommendations</li>
<li> Upsell/cross-sell campaigns</li>
<li> Birthday or anniversary emails</li>
<li> Renewal reminders (if promotional)</li>
<li> Review requests</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold text-neutral-900 mb-4">Marketing Email Best Practices</h3>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Always Get Explicit Consent</h4>
<p className="text-neutral-700">
Use clear opt-in forms (preferably double opt-in). Never add people without permission or use purchased
lists. GDPR and CAN-SPAM require explicit consent for marketing emails.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Provide Value, Not Just Promotions</h4>
<p className="text-neutral-700">
Balance promotional content with educational value, entertainment, or useful information. Subscribers
who only receive sales pitches will unsubscribe.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Segment Your Audience</h4>
<p className="text-neutral-700">
Send targeted emails based on behavior, preferences, demographics, or engagement. Segmented campaigns
see 3x higher engagement than mass blasts.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Make Unsubscribing Easy</h4>
<p className="text-neutral-700">
Include a clear, one-click unsubscribe link in every email (required by law). Making it hard to
unsubscribe leads to spam complaints, which are much worse for deliverability.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-2">Test and Optimize</h4>
<p className="text-neutral-700">
A/B test subject lines, send times, content, and CTAs. Marketing emails benefit from continuous
optimization based on performance data.
</p>
</div>
</div>
</div>
</section>
{/* Legal Requirements */}
<section id="legal" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Legal Requirements</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
The legal distinction between transactional and marketing emails is critical. Violating these rules can result
in significant fines and damage to your reputation.
</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<h3 className="text-xl font-semibold text-blue-900 mb-4">Transactional Email Laws</h3>
<div className="space-y-3 text-neutral-700">
<div>
<h4 className="font-semibold text-neutral-900 mb-1">CAN-SPAM (US)</h4>
<p className="text-sm">Exempt from most requirements but must not include false/misleading headers</p>
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-1">GDPR (EU)</h4>
<p className="text-sm">
Allowed as "legitimate interest" for service provision. Must still protect user data and allow opt-out
of optional transactional emails
</p>
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-1">Key Point</h4>
<p className="text-sm">
Transactional emails don't require explicit consent or unsubscribe links, but abuse this category (by
making promotional emails appear transactional) violates laws
</p>
</div>
</div>
</div>
<div className="rounded-xl border-2 border-purple-200 bg-purple-50 p-6">
<h3 className="text-xl font-semibold text-purple-900 mb-4">Marketing Email Laws</h3>
<div className="space-y-3 text-neutral-700">
<div>
<h4 className="font-semibold text-neutral-900 mb-1">CAN-SPAM (US)</h4>
<ul className="text-sm space-y-1">
<li> Clear opt-out mechanism (unsubscribe link)</li>
<li> Honor opt-outs within 10 business days</li>
<li> Include physical mailing address</li>
<li> Identify message as advertisement</li>
<li> No false/misleading subject lines or headers</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-neutral-900 mb-1">GDPR (EU)</h4>
<ul className="text-sm space-y-1">
<li> Explicit opt-in consent required</li>
<li> Clear information about data usage</li>
<li> Easy opt-out anytime</li>
<li> Right to data access/deletion</li>
<li> Records of consent</li>
</ul>
</div>
</div>
</div>
</div>
<InfoBox type="warning" title="Don't Abuse the Transactional Category" className="mt-6">
<p>
It's illegal and unethical to disguise marketing emails as transactional to avoid consent requirements.
Regulators and ISPs actively monitor for this abuse. Keep transactional emails purely functional.
</p>
</InfoBox>
</section>
{/* Infrastructure Considerations */}
<section id="infrastructure" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Infrastructure & Sending Practices</h2>
<div className="space-y-6">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Separate Sending Domains</h3>
<p className="text-neutral-700 mb-3">Use different subdomains for transactional and marketing emails:</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Transactional:</strong> transact.yourdomain.com or mail.yourdomain.com
</li>
<li>
<strong>Marketing:</strong> marketing.yourdomain.com or news.yourdomain.com
</li>
<li>
<strong>Benefit:</strong> Protects critical transactional email reputation from marketing
deliverability issues
</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">Dedicated IP Addresses (High Volume)</h3>
<p className="text-neutral-700 mb-3">For organizations sending 100,000+ emails/month:</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li> Use separate dedicated IPs for transactional and marketing</li>
<li> Properly warm up new IPs to build sender reputation</li>
<li> Monitor reputation for each IP independently</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">Monitoring & Analytics</h3>
<p className="text-neutral-700 mb-3">Track metrics separately for each type:</p>
<ul className="space-y-2 text-sm text-neutral-700">
<li>
<strong>Transactional:</strong> Delivery rate (target: 99%+), delivery speed, bounce rate
</li>
<li>
<strong>Marketing:</strong> Open rate, CTR, conversions, engagement, unsubscribe rate
</li>
</ul>
</div>
</div>
</section>
{/* The Gray Area */}
<section id="gray-area" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">The Gray Area: What's What?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Some emails can be tricky to categorize. Here's guidance:
</p>
<div className="space-y-4">
<div className="rounded-xl border-2 border-green-200 bg-green-50 p-6">
<h3 className="text-xl font-semibold text-green-900 mb-3">Clearly Transactional</h3>
<ul className="space-y-2 text-neutral-700">
<li> Order confirmations and shipping updates</li>
<li> Password resets and account verification</li>
<li> Billing receipts and invoices</li>
<li> System alerts and security notifications</li>
</ul>
</div>
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<h3 className="text-xl font-semibold text-amber-900 mb-3">Gray Area (Be Careful)</h3>
<ul className="space-y-2 text-neutral-700">
<li>
<strong>Cart abandonment:</strong> If purely reminder = transactional. If includes promotions =
marketing
</li>
<li>
<strong>Review requests:</strong> If tied to recent purchase = transactional. If general request =
marketing
</li>
<li>
<strong>Welcome emails:</strong> Initial verification = transactional. Follow-up series = marketing
</li>
<li>
<strong>Renewal reminders:</strong> If informational only = transactional. If promotional = marketing
</li>
</ul>
</div>
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<h3 className="text-xl font-semibold text-red-900 mb-3">Clearly Marketing</h3>
<ul className="space-y-2 text-neutral-700">
<li> Newsletters and content roundups</li>
<li> Product announcements and promotions</li>
<li> Sales and discount offers</li>
<li> Educational content and webinars</li>
<li> Re-engagement campaigns</li>
</ul>
</div>
</div>
<InfoBox type="tip" title="When in Doubt" className="mt-6">
<p>
If you're unsure whether an email is transactional or marketing, err on the side of treating it as
marketing. Include an unsubscribe link, get proper consent, and follow all marketing email regulations.
</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 Guides</h2>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/email-api-guide"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email API Guide</h3>
<p className="text-sm text-neutral-600">Implement transactional emails with APIs.</p>
</Link>
<Link
href="/guides/email-marketing-best-practices"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Marketing Best Practices</h3>
<p className="text-sm text-neutral-600">Complete marketing email guide.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability</h3>
<p className="text-sm text-neutral-600">Ensure both email types reach the inbox.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,332 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
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."
lastUpdated="2025-12-20"
readTime="8 min"
canonical="https://www.useplunk.com/guides/what-is-dkim"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
DKIM (DomainKeys Identified Mail) is an email authentication method that allows receiving mail servers to
verify that an email was actually sent by the domain it claims to be from and that the message wasn't altered
in transit.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
Think of DKIM as a digital signature for your emailslike a wax seal on a letter that proves it's authentic
and hasn't been tampered with.
</p>
</section>
{/* How DKIM Works */}
<section id="how-dkim-works" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How DKIM Works</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
DKIM uses cryptographic authentication to validate emails. Here's the process:
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. The Sending Server Signs the Email</h3>
<p className="text-neutral-700">
When you send an email, your email server adds a DKIM signature to the email header. This signature is
created using a private key that only your server knows.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. The Signature is Added to Headers</h3>
<p className="text-neutral-700">
The DKIM signature includes a hash of specific email components (like the subject, body, and sender) and
is added to the email headers as a "DKIM-Signature" field.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Verifies</h3>
<p className="text-neutral-700">
When the email arrives, the receiving server looks up your domain's public DKIM key in DNS, then uses it
to verify the signature. If everything matches, the email passes DKIM authentication.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Delivery Decision</h3>
<p className="text-neutral-700">
Passing DKIM verification improves your sender reputation and deliverability. Failing or missing DKIM may
result in emails being flagged as suspicious or sent to spam.
</p>
</div>
</div>
<InfoBox type="tip" title="Technical Detail">
<p>
DKIM uses asymmetric cryptography (public/private key pairs). The private key stays secure on your mail
server, while the public key is published in your DNS records for anyone to verify.
</p>
</InfoBox>
</section>
{/* DKIM Record Example */}
<section id="dkim-record-example" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Does a DKIM Record Look Like?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
A DKIM record is a TXT record in your DNS that contains your public key. Here's an example:
</p>
<div className="w-full max-w-full overflow-x-auto">
<CodeBlock
language="dns"
title="Example DKIM DNS Record"
code={`default._domainkey.yourdomain.com IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC3QEKyU1fSma0axspqYK5iAj+54lsAg4qRRCnpKK68hawSJfliq9vKD6czJ..."
# Breaking down the components:
# v=DKIM1 -> DKIM version
# k=rsa -> Key type (RSA encryption)
# p=MIGfMA0... -> Public key (base64 encoded)`}
/>
</div>
<InfoBox type="info" title="Selector Names">
<p>
The "default" in <code>default._domainkey</code> is called a selector. You can use different selectors to
rotate keys or separate different email streams (e.g., marketing, transactional).
</p>
</InfoBox>
</section>
{/* Why DKIM Matters */}
<section id="why-dkim-matters" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Why DKIM Matters for Email Deliverability</h2>
<div className="grid gap-6 md:grid-cols-2 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Prevents Email Spoofing</h3>
<p className="text-neutral-700">
DKIM makes it nearly impossible for spammers to forge emails from your domain. The cryptographic
signature can't be replicated without your private key.
</p>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Improves Deliverability</h3>
<p className="text-neutral-700">
Major email providers (Gmail, Outlook, Yahoo) use DKIM as a trust signal. Emails with valid DKIM
signatures are more likely to reach the inbox.
</p>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Protects Brand Reputation</h3>
<p className="text-neutral-700">
By preventing domain spoofing, DKIM protects your brand from being used in phishing attacks that could
damage your reputation.
</p>
</div>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<div className="w-full max-w-full wrap-break-word">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Enables DMARC</h3>
<p className="text-neutral-700">
DKIM is a prerequisite for implementing DMARC, which provides even stronger email authentication and
reporting capabilities.
</p>
</div>
</div>
</div>
<InfoBox type="warning" title="Gmail & Yahoo Requirements">
<p>
As of February 2024, Gmail and Yahoo require DKIM authentication for bulk senders (5,000+ emails/day). Even
if you send less, implementing DKIM is considered a best practice.
</p>
</InfoBox>
</section>
{/* DKIM Signature Example */}
<section id="dkim-signature-example" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Does a DKIM Signature Look Like?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
When you send an email, the DKIM signature is added to the email headers. Here's what it looks like:
</p>
<CodeBlock
language="text"
title="DKIM-Signature Header Example"
code={`DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=yourdomain.com; s=default;
h=from:subject:date:message-id:to;
bh=frcCV1k9oG9oKj3dpUqdJg1PxRT2RSN/XKdLCPjaYaY=;
b=GJwP3Qr8KqKKKNT5HL8j3fjXvLEm9KmZs6YdO2KqEqr...
# Key components:
# v=1 -> DKIM version
# d=yourdomain.com -> Signing domain
# s=default -> Selector (matches DNS record)
# h=from:subject... -> Headers included in signature
# bh=frcCV1... -> Hash of email body
# b=GJwP3Q... -> The actual signature`}
/>
</section>
{/* How Plunk Handles DKIM */}
<section id="plunk-dkim" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Plunk Simplifies DKIM</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Setting up DKIM manually can be complex, but Plunk makes it automatic:
</p>
<div className="space-y-4 mb-8">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Automatic Key Generation</h3>
<p className="text-neutral-700">
Plunk automatically generates secure DKIM key pairs for your domain when you add it to your account.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Simple DNS Setup</h3>
<p className="text-neutral-700">
We provide the exact DNS records you need to addjust copy and paste into your DNS provider.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Automatic Signing</h3>
<p className="text-neutral-700">
Every email you send through Plunk is automatically signed with DKIM. No configuration needed.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Verification & Monitoring</h3>
<p className="text-neutral-700">
Plunk verifies your DKIM setup and monitors authentication status for all your emails.
</p>
</div>
</div>
</div>
<InfoBox type="success" title="Ready in Minutes">
<p>
Most Plunk users have DKIM fully configured and working within 5-10 minutes. Our dashboard guides you
through every step.
</p>
</InfoBox>
</section>
{/* DKIM Best Practices */}
<section id="dkim-best-practices" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM Best Practices</h2>
<div className="space-y-6">
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Use 2048-bit Keys</h3>
<p className="text-neutral-700">
While 1024-bit keys still work, 2048-bit keys provide better security and are recommended by Gmail and
other providers.
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Implement SPF and DMARC Too</h3>
<p className="text-neutral-700">
DKIM works best when combined with SPF and DMARC for comprehensive email authentication. Use all three for
maximum protection.
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Monitor DKIM Status</h3>
<p className="text-neutral-700">
Regularly check that your DKIM signatures are passing. Most email platforms provide authentication
reports.
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Rotate Keys Periodically</h3>
<p className="text-neutral-700">
For enhanced security, rotate your DKIM keys every 6-12 months. Plan key rotation carefully to avoid
delivery disruptions.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Don't Share Private Keys</h3>
<p className="text-neutral-700">
Your DKIM private key should never be shared or stored insecurely. Treat it like a password.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Don't Use the Same Key Across Domains</h3>
<p className="text-neutral-700">
Each domain should have its own unique DKIM key pair for security and proper authentication.
</p>
</div>
</div>
</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>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/what-is-spf"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is SPF?</h3>
<p className="text-sm text-neutral-600">Learn about SPF records and how they complement DKIM.</p>
</Link>
<Link
href="/guides/what-is-dmarc"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DMARC?</h3>
<p className="text-sm text-neutral-600">Complete guide to DMARC policies and email security.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability Guide</h3>
<p className="text-sm text-neutral-600">Improve your email deliverability with best practices.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,351 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
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."
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/what-is-dmarc"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an email authentication protocol
that builds on SPF and DKIM to protect your domain from email spoofing and phishing.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
While SPF and DKIM authenticate emails, DMARC tells receiving servers what to do when authentication fails and
provides reports about your email authentication status.
</p>
</section>
{/* How DMARC Works */}
<section id="how-dmarc-works" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How DMARC Works</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
DMARC adds a policy layer on top of SPF and DKIM. Here's how it works:
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Email Authentication</h3>
<p className="text-neutral-700">
When an email is received, the server first checks SPF and DKIM authentication. At least one of these must
pass for DMARC to pass.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Alignment Check</h3>
<p className="text-neutral-700">
DMARC checks if the domain in the "From" header aligns with the domain that passed SPF or DKIM. This is
called "identifier alignment."
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Policy Application</h3>
<p className="text-neutral-700">
If authentication and alignment pass, the email is delivered. If they fail, the receiving server follows
your DMARC policy: none (monitor only), quarantine (send to spam), or reject (block completely).
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Reporting</h3>
<p className="text-neutral-700">
Receiving servers send daily reports to your specified email address, showing authentication results for
all emails claiming to be from your domain.
</p>
</div>
</div>
<InfoBox type="info" title="DMARC Requires SPF or DKIM">
<p>
DMARC doesn't work aloneyou must have SPF and/or DKIM configured first. DMARC builds on these protocols to
provide policy enforcement and reporting.
</p>
</InfoBox>
</section>
{/* DMARC Record Syntax */}
<section id="dmarc-record-syntax" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DMARC Record Syntax</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
A DMARC record is a TXT record published at <code>_dmarc.yourdomain.com</code>. Here's an example:
</p>
<CodeBlock
language="dns"
title="Example DMARC Record"
code={`_dmarc.yourdomain.com IN TXT "v=DMARC1; p=quarantine; rua=mailto:[email protected]; ruf=mailto:[email protected]; pct=100; adkim=r; aspf=r"
# Breaking down the components:
# v=DMARC1 -> DMARC version
# p=quarantine -> Policy (none, quarantine, or reject)
# rua=mailto:dmarc@yourdomain.com -> Aggregate report email
# ruf=mailto:forensic@yourdomain.com -> Forensic report email
# pct=100 -> Percentage of mail to apply policy (100%)
# adkim=r -> DKIM alignment mode (r=relaxed, s=strict)
# aspf=r -> SPF alignment mode (r=relaxed, s=strict)`}
/>
<div className="mt-8 space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">DMARC Policy Tags</h3>
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-neutral-900">Tag</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-neutral-900">Description</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-neutral-900">Required</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200">
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">v</td>
<td className="px-6 py-4 text-sm text-neutral-700">DMARC version (always DMARC1)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">p</td>
<td className="px-6 py-4 text-sm text-neutral-700">Policy: none, quarantine, or reject</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">rua</td>
<td className="px-6 py-4 text-sm text-neutral-700">Aggregate report email address</td>
<td className="px-6 py-4 text-sm text-neutral-700">Recommended</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">ruf</td>
<td className="px-6 py-4 text-sm text-neutral-700">Forensic report email address</td>
<td className="px-6 py-4 text-sm text-neutral-700">Optional</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">pct</td>
<td className="px-6 py-4 text-sm text-neutral-700">Percentage of mail to filter (0-100)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Optional</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">sp</td>
<td className="px-6 py-4 text-sm text-neutral-700">Policy for subdomains</td>
<td className="px-6 py-4 text-sm text-neutral-700">Optional</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">adkim</td>
<td className="px-6 py-4 text-sm text-neutral-700">DKIM alignment: r (relaxed) or s (strict)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Optional</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">aspf</td>
<td className="px-6 py-4 text-sm text-neutral-700">SPF alignment: r (relaxed) or s (strict)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Optional</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
{/* DMARC Policies */}
<section id="dmarc-policies" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Understanding DMARC Policies</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
DMARC offers three policy levels. You should implement them progressively:
</p>
<div className="space-y-6">
<div className="rounded-xl border-2 border-blue-200 bg-blue-50 p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white font-bold">
1
</div>
<div>
<h3 className="text-xl font-semibold text-blue-900 mb-2">p=none (Monitor Mode)</h3>
<p className="text-neutral-700 mb-3">
No action is taken on failed emailsthey're still delivered. Use this initially to monitor your email
authentication without affecting delivery.
</p>
<CodeBlock language="dns" code={`v=DMARC1; p=none; rua=mailto:[email protected]`} showCopy={false} />
<p className="text-sm text-neutral-600 mt-3">
<strong>Best for:</strong> Initial setup, gathering data, testing configuration
</p>
</div>
</div>
</div>
<div className="rounded-xl border-2 border-amber-200 bg-amber-50 p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-600 text-white font-bold">
2
</div>
<div>
<h3 className="text-xl font-semibold text-amber-900 mb-2">p=quarantine (Quarantine Failed Mail)</h3>
<p className="text-neutral-700 mb-3">
Emails that fail authentication are sent to spam/junk folders. This is a good middle ground that
protects your domain while minimizing delivery issues.
</p>
<CodeBlock
language="dns"
code={`v=DMARC1; p=quarantine; rua=mailto:[email protected]; pct=100`}
showCopy={false}
/>
<p className="text-sm text-neutral-600 mt-3">
<strong>Best for:</strong> After monitoring, when you're confident in your setup
</p>
</div>
</div>
</div>
<div className="rounded-xl border-2 border-red-200 bg-red-50 p-6">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-600 text-white font-bold">
3
</div>
<div>
<h3 className="text-xl font-semibold text-red-900 mb-2">p=reject (Block Failed Mail)</h3>
<p className="text-neutral-700 mb-3">
Emails that fail authentication are completely rejected and not delivered. This provides maximum
protection but requires perfect configuration.
</p>
<CodeBlock
language="dns"
code={`v=DMARC1; p=reject; rua=mailto:[email protected]; pct=100`}
showCopy={false}
/>
<p className="text-sm text-neutral-600 mt-3">
<strong>Best for:</strong> Mature implementations with complete authentication coverage
</p>
</div>
</div>
</div>
</div>
<InfoBox type="warning" title="Progressive Implementation">
<p>
Always start with <code>p=none</code> and monitor for at least 2-4 weeks. Review DMARC reports, fix any
authentication issues, then gradually move to <code>p=quarantine</code> and finally <code>p=reject</code>.
</p>
</InfoBox>
</section>
{/* Setting Up DMARC */}
<section id="setting-up-dmarc" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Set Up DMARC</h2>
<div className="space-y-4 mb-8">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Ensure SPF and DKIM are Working</h3>
<p className="text-neutral-700">
DMARC requires either SPF or DKIM (or both) to be configured. Verify these are working before
implementing DMARC.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Create a Mailbox for Reports</h3>
<p className="text-neutral-700">
Set up an email address to receive DMARC reports (e.g., dmarc@yourdomain.com). These reports can be
large and frequent, so use a dedicated mailbox.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Create Your DMARC Record</h3>
<p className="text-neutral-700 mb-3">Start with a monitoring-only policy:</p>
<CodeBlock language="dns" code={`v=DMARC1; p=none; rua=mailto:[email protected]; pct=100`} />
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Add to DNS</h3>
<p className="text-neutral-700">
Add the DMARC record as a TXT record at <code>_dmarc.yourdomain.com</code>
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Monitor Reports</h3>
<p className="text-neutral-700">
Review DMARC reports for 2-4 weeks. Look for failed authentications and identify any legitimate sources
that need SPF/DKIM configuration.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
6
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Gradually Increase Policy</h3>
<p className="text-neutral-700">
Once confident, update to <code>p=quarantine</code>, monitor again, then move to <code>p=reject</code>{' '}
if desired.
</p>
</div>
</div>
</div>
</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>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/what-is-dkim"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DKIM?</h3>
<p className="text-sm text-neutral-600">Learn how DKIM authenticates email content.</p>
</Link>
<Link
href="/guides/what-is-spf"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is SPF?</h3>
<p className="text-sm text-neutral-600">Understand SPF records and server authorization.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability Guide</h3>
<p className="text-sm text-neutral-600">Complete guide to improving email deliverability.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
@@ -0,0 +1,334 @@
import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
export default function WhatIsSPF() {
return (
<GuideLayout
title="What is SPF? Email Sender Policy Framework Explained"
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
lastUpdated="2025-12-20"
readTime="7 min"
canonical="https://www.useplunk.com/guides/what-is-spf"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
<p className="text-neutral-700 leading-relaxed">
SPF (Sender Policy Framework) is an email authentication protocol that allows domain owners to specify which
mail servers are authorized to send emails on behalf of their domain.
</p>
<p className="mt-4 text-neutral-700 leading-relaxed">
Think of SPF as a guest list for your domainit tells receiving servers, "These are the only servers allowed
to send email using my domain name."
</p>
</section>
{/* How SPF Works */}
<section id="how-spf-works" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How SPF Works</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
SPF works by publishing a list of authorized sending servers in your DNS records. Here's the process:
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. You Publish an SPF Record</h3>
<p className="text-neutral-700">
You add a TXT record to your domain's DNS that lists all IP addresses and services authorized to send
email from your domain.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. An Email is Sent</h3>
<p className="text-neutral-700">
When someone sends an email claiming to be from your domain, the receiving server notes the IP address of
the sending server.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Checks SPF</h3>
<p className="text-neutral-700">
The receiving server looks up your domain's SPF record in DNS and checks if the sending server's IP
address is listed as authorized.
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Pass or Fail</h3>
<p className="text-neutral-700">
If the IP matches, SPF passes. If not, SPF fails and the email may be flagged as spam or rejected,
depending on your policy.
</p>
</div>
</div>
<InfoBox type="info" title="SPF Validates the Server, Not the Content">
<p>
Unlike DKIM which validates email content, SPF only checks if the sending server is authorized. This is why
using both SPF and DKIM together provides stronger authentication.
</p>
</InfoBox>
</section>
{/* SPF Record Syntax */}
<section id="spf-record-syntax" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">SPF Record Syntax</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
An SPF record is a TXT record with a specific format. Here's a typical example:
</p>
<CodeBlock
language="dns"
title="Example SPF Record"
code={`v=spf1 include:_spf.google.com include:sendgrid.net ip4:192.0.2.1 ~all
# Breaking down the components:
# v=spf1 -> SPF version (always v=spf1)
# include:_spf.google.com -> Include Google's SPF record
# include:sendgrid.net -> Include SendGrid's SPF record
# ip4:192.0.2.1 -> Authorize specific IPv4 address
# ~all -> Soft fail for all others`}
/>
<div className="mt-8 space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Common SPF Mechanisms</h3>
<div className="rounded-xl border border-neutral-200 overflow-hidden">
<table className="w-full">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-3 text-left text-sm font-semibold text-neutral-900">Mechanism</th>
<th className="px-6 py-3 text-left text-sm font-semibold text-neutral-900">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200">
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">ip4:192.0.2.1</td>
<td className="px-6 py-4 text-sm text-neutral-700">Authorize specific IPv4 address</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">ip6:2001:db8::1</td>
<td className="px-6 py-4 text-sm text-neutral-700">Authorize specific IPv6 address</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">include:domain.com</td>
<td className="px-6 py-4 text-sm text-neutral-700">Include another domain's SPF record</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">a</td>
<td className="px-6 py-4 text-sm text-neutral-700">Authorize domain's A record IP</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">mx</td>
<td className="px-6 py-4 text-sm text-neutral-700">Authorize domain's MX record IPs</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">~all</td>
<td className="px-6 py-4 text-sm text-neutral-700">Soft fail (treat others as suspicious)</td>
</tr>
<tr>
<td className="px-6 py-4 font-mono text-sm text-neutral-900">-all</td>
<td className="px-6 py-4 text-sm text-neutral-700">Hard fail (reject all others)</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<InfoBox type="warning" title="10 DNS Lookup Limit">
<p>
SPF has a hard limit of 10 DNS lookups. Each <code>include:</code> mechanism counts as one lookup. Exceeding
this limit causes SPF validation to fail. Keep your SPF record concise and avoid excessive includes.
</p>
</InfoBox>
</section>
{/* Why SPF Matters */}
<section id="why-spf-matters" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Why SPF Matters</h2>
<div className="grid gap-6 md:grid-cols-2 mb-8">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Prevents Email Spoofing</h3>
<p className="text-neutral-700">
SPF makes it much harder for spammers to send emails that appear to come from your domain. Only authorized
servers can send on your behalf.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Improves Deliverability</h3>
<p className="text-neutral-700">
Emails from domains with proper SPF records are more trusted by receiving servers, leading to better inbox
placement rates.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Protects Your Domain</h3>
<p className="text-neutral-700">
By specifying authorized senders, you protect your domain from being used in phishing and spam campaigns.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">Required for DMARC</h3>
<p className="text-neutral-700">
SPF (along with DKIM) is necessary for implementing DMARC, which provides comprehensive email
authentication and reporting.
</p>
</div>
</div>
</section>
{/* Setting Up SPF */}
<section id="setting-up-spf" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Set Up SPF</h2>
<div className="space-y-4 mb-8">
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
1
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Identify All Email Senders</h3>
<p className="text-neutral-700">
List all services and servers that send email from your domain: your email service provider, marketing
tools, support systems, etc.
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
2
</div>
<div className="w-full max-w-full wrap-break-word">
<h3 className="font-semibold text-neutral-900 mb-2">Gather SPF Include Values</h3>
<p className="text-neutral-700 mb-3">Each email service provides SPF values to include. For example:</p>
<CodeBlock
language="text"
code={`Google Workspace: include:_spf.google.com\nMicrosoft 365: include:spf.protection.outlook.com\nPlunk: include:spf.useplunk.com\nSendGrid: include:sendgrid.net`}
showCopy={false}
/>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
3
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Create Your SPF Record</h3>
<p className="text-neutral-700 mb-3">Combine all authorized senders into one SPF record:</p>
<CodeBlock language="dns" code={`v=spf1 include:_spf.google.com include:spf.useplunk.com ~all`} />
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
4
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Add to DNS</h3>
<p className="text-neutral-700">
Add the SPF record as a TXT record in your DNS settings. The record name should be your root domain
(e.g., "@" or "yourdomain.com").
</p>
</div>
</div>
<div className="flex items-start gap-4 p-6 rounded-xl bg-neutral-50 border border-neutral-200">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-neutral-900 text-white text-sm font-bold">
5
</div>
<div>
<h3 className="font-semibold text-neutral-900 mb-2">Verify SPF</h3>
<p className="text-neutral-700">
Use SPF validation tools to confirm your record is correct and doesn't exceed the 10 DNS lookup limit.
</p>
</div>
</div>
</div>
<InfoBox type="success" title="Plunk Handles This Automatically">
<p>
When you set up a domain in Plunk, we provide the exact SPF record you need. Just copy and paste it into
your DNS, and we'll verify it's working correctly.
</p>
</InfoBox>
</section>
{/* Common SPF Mistakes */}
<section id="common-mistakes" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common SPF Mistakes to Avoid</h2>
<div className="space-y-6">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Multiple SPF Records</h3>
<p className="text-neutral-700">
Never create multiple SPF TXT records. You can only have ONE SPF record per domain. Combine all authorized
senders into a single record.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Exceeding 10 DNS Lookups</h3>
<p className="text-neutral-700">
Each <code>include:</code> mechanism counts toward the 10 lookup limit. Too many includes will cause SPF
to fail. Consolidate where possible.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Forgetting to Update SPF</h3>
<p className="text-neutral-700">
When you add new email services, remember to update your SPF record. Outdated SPF records cause legitimate
emails to fail authentication.
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Using +all</h3>
<p className="text-neutral-700">
Never use <code>+all</code> (pass all). This completely defeats the purpose of SPF by allowing anyone to
send from your domain. Always use <code>~all</code> or <code>-all</code>.
</p>
</div>
</div>
</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>
<div className="grid gap-4 md:grid-cols-3">
<Link
href="/guides/what-is-dkim"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DKIM?</h3>
<p className="text-sm text-neutral-600">Learn how DKIM complements SPF for email authentication.</p>
</Link>
<Link
href="/guides/what-is-dmarc"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">What is DMARC?</h3>
<p className="text-sm text-neutral-600">Implement DMARC using SPF and DKIM together.</p>
</Link>
<Link
href="/guides/email-deliverability"
className="block rounded-xl border border-neutral-200 bg-white p-6 transition hover:border-neutral-300 hover:shadow-lg"
>
<h3 className="text-lg font-semibold text-neutral-900 mb-2">Email Deliverability Guide</h3>
<p className="text-sm text-neutral-600">Complete guide to improving email deliverability.</p>
</Link>
</div>
</section>
</GuideLayout>
);
}
+113 -4
View File
@@ -26,7 +26,7 @@ import {
TrendingUp,
User,
Users,
Workflow
Workflow,
} from 'lucide-react';
const testimonials = [
@@ -157,7 +157,7 @@ export default function Index() {
'acceptedAnswer': {
'@type': 'Answer',
'text':
'Plunk offers a free plan that includes 3000 automated, 3000 transactional and 3000 broadcast emails per month. You can upgrade to a pay-as-you-go plan that costs $0.001 per email.',
'Plunk offers a free plan that includes 1000 emails per month. You can upgrade to a pay-as-you-go plan that costs $0.001 per email.',
},
},
],
@@ -179,6 +179,32 @@ export default function Index() {
}),
}}
/>
<Script
id={`software-schema-index`}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
'name': 'Plunk',
'applicationCategory': 'Email Marketing Software',
'operatingSystem': 'Web, Docker',
'offers': {
'@type': 'Offer',
'price': '0',
'priceCurrency': 'USD',
'priceSpecification': {
'@type': 'UnitPriceSpecification',
'price': '0.001',
'priceCurrency': 'USD',
'unitText': 'email',
},
},
'featureList':
'Workflow Automation, Dynamic Segmentation, Campaign Management, Analytics, Developer API, Custom Domains, Self-Hosting, Open Source',
}),
}}
/>
<Navbar />
@@ -199,9 +225,9 @@ export default function Index() {
className={'mx-auto max-w-4xl text-center'}
>
<h1 className={'text-7xl font-bold tracking-tight text-neutral-900 sm:text-8xl lg:text-9xl'}>
Open-source
Open-Source
<br />
email platform
Email Platform
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Open-source email automation. Build workflows, segment audiences, and send emails with a simple API.
@@ -234,6 +260,71 @@ export default function Index() {
</motion.div>
</section>
{/* Alternatives */}
<section className={'py-32'}>
<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-5xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900'}>
Replace Resend, SendGrid, Mailchimp, and More
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
The open-source alternative to proprietary email platforms
</p>
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-5'}>
<Link href={'/vs/resend'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
<div className={'flex flex-col items-center gap-4'}>
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
Resend
</div>
<span className={'text-xs font-medium text-neutral-500'}>vs Plunk </span>
</div>
</Link>
<Link href={'/vs/sendgrid'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
<div className={'flex flex-col items-center gap-4'}>
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
SendGrid
</div>
<span className={'text-xs font-medium text-neutral-500'}>vs Plunk </span>
</div>
</Link>
<Link href={'/vs/mailchimp'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
<div className={'flex flex-col items-center gap-4'}>
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
Mailchimp
</div>
<span className={'text-xs font-medium text-neutral-500'}>vs Plunk </span>
</div>
</Link>
<Link href={'/vs/customerio'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
<div className={'flex flex-col items-center gap-4'}>
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
Customer.io
</div>
<span className={'text-xs font-medium text-neutral-500'}>vs Plunk </span>
</div>
</Link>
<Link href={'/vs/mailgun'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
<div className={'flex flex-col items-center gap-4'}>
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
Mailgun
</div>
<span className={'text-xs font-medium text-neutral-500'}>vs Plunk </span>
</div>
</Link>
</div>
</motion.div>
</section>
{/* Problem Statement */}
<section className={'py-32'}>
<motion.div
@@ -523,6 +614,7 @@ export default function Index() {
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-2 text-sm text-neutral-600'}>AGPL-3.0 licensed</p>
<p className={'mt-4 text-xs text-neutral-500'}>4K+ stars on GitHub</p>
</div>
<div className={'bg-white p-12'}>
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
@@ -530,6 +622,7 @@ export default function Index() {
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Privacy First</h3>
<p className={'mt-2 text-sm text-neutral-600'}>EU hosted</p>
<p className={'mt-4 text-xs text-neutral-500'}>GDPR compliant</p>
</div>
<div className={'bg-white p-12'}>
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
@@ -537,8 +630,24 @@ export default function Index() {
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-2 text-sm text-neutral-600'}>Deploy anywhere</p>
<p className={'mt-4 text-xs text-neutral-500'}>Docker Compose ready</p>
</div>
</div>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={'https://github.com/useplunk/plunk'}
target={'_blank'}
className={
'group inline-flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-6 py-3 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View on GitHub
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</motion.a>
</div>
</motion.div>
</section>
File diff suppressed because one or more lines are too long
+194 -19
View File
@@ -15,12 +15,12 @@ export default function Index() {
<NextSeo
title={'Plunk Pricing | The Open-Source Email Platform'}
description={
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 3,000 emails/month. No hidden fees, pay only for what you use.'
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month across transactional, workflow, and campaign emails. No hidden fees, pay only for what you use.'
}
openGraph={{
title: 'Plunk Pricing | The Open-Source Email Platform',
description:
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 3,000 emails/month. No hidden fees, pay only for what you use.',
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month across transactional, workflow, and campaign emails. No hidden fees, pay only for what you use.',
}}
additionalMetaTags={[
{
@@ -42,27 +42,202 @@ export default function Index() {
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">Start free, pay only for what you use</p>
</div>
{/* Main Pricing Display */}
<div className="mx-auto mt-20 max-w-2xl text-center">
<div className="flex items-baseline justify-center gap-3">
<span className="text-7xl font-bold tracking-tight text-neutral-900">$0.001</span>
<span className="text-2xl text-neutral-500">/email</span>
</div>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'mx-auto mt-10 inline-block rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
{/* Pricing Tiers */}
<div className="mx-auto mt-20 grid max-w-5xl gap-8 lg:grid-cols-2">
{/* Free Tier */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.2}}
className="relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-neutral-900 via-neutral-800 to-neutral-900 p-10 shadow-2xl"
>
Get started for free
</motion.a>
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
<div className="relative flex flex-1 flex-col">
<div className="mb-6 inline-block self-start rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
Free Forever
</div>
<div className="mb-2 flex items-baseline gap-2">
<span className="text-6xl font-bold tracking-tight text-white">1,000</span>
<span className="text-xl font-medium text-white/60">emails</span>
</div>
<p className="mb-8 text-base text-white/60">per month</p>
<ul className="flex-1 space-y-3">
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Transactional emails</span>
</li>
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Workflow automation</span>
</li>
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Campaign broadcasts</span>
</li>
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Custom domains</span>
</li>
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Click & open tracking</span>
</li>
<li className="flex items-start gap-3 text-white/80">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>No credit card required</span>
</li>
<li className="flex items-start gap-3 text-white/60">
<svg className="mt-0.5 h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
<span>Plunk branding</span>
</li>
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className="mt-8 block w-full rounded-lg border-2 border-white bg-white px-6 py-3 text-center font-semibold text-neutral-900 transition hover:bg-white/90"
>
Start for free
</motion.a>
</div>
</motion.div>
{/* Pay as you grow */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.3}}
className="relative flex flex-col overflow-hidden rounded-2xl border-2 border-neutral-200 bg-white p-10 shadow-lg"
>
<div className="mb-6 inline-block self-start rounded-full bg-neutral-100 px-4 py-1.5 text-sm font-semibold text-neutral-900">
Pay as you grow
</div>
<div className="mb-2 flex items-baseline gap-2">
<span className="text-6xl font-bold tracking-tight text-neutral-900">$0.001</span>
<span className="text-xl font-medium text-neutral-500">per email</span>
</div>
<p className="mb-8 text-base text-neutral-500">No contact limits, pay only for what you send</p>
<ul className="flex-1 space-y-3">
<li className="flex items-start gap-3 text-neutral-600">
<svg
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Everything in Free</span>
</li>
<li className="flex items-start gap-3 text-neutral-600">
<svg
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>No Plunk branding</span>
</li>
<li className="flex items-start gap-3 text-neutral-600">
<svg
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Billing limits</span>
</li>
<li className="flex items-start gap-3 text-neutral-600">
<svg
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
<span>Unlimited emails</span>
</li>
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className="mt-8 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center font-semibold text-white transition hover:bg-neutral-800"
>
Start for free
</motion.a>
</motion.div>
</div>
{/* Features Grid */}
<div className="mx-auto mt-20 max-w-5xl">
<div className="mx-auto mt-24 max-w-5xl">
<h2 className="mb-12 text-center text-2xl font-bold text-neutral-900">Everything included</h2>
<div className="grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-white p-8">
+3 -1
View File
@@ -12,7 +12,9 @@ export default function Index() {
<>
<NextSeo
title={'Plunk Terms of Service | The Open-Source Email Platform'}
description={'Terms of Service for Plunk, the open-source email automation platform. Read our user agreement and service terms.'}
description={
'Terms of Service for Plunk, the open-source email automation platform. Read our user agreement and service terms.'
}
openGraph={{
title: 'Plunk Terms of Service | The Open-Source Email Platform',
description:
+270
View File
@@ -0,0 +1,270 @@
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, Mail, Search, Sparkles, Wrench} from 'lucide-react';
const tools = [
{
name: 'Markdown to Email',
slug: 'markdown-to-email',
description: 'Convert rich text to email-safe HTML with our visual editor and instant preview.',
features: ['Visual Editor', 'Email-Safe HTML', 'Inline CSS', 'Copy to Clipboard'],
icon: Code2,
},
{
name: 'Email Verification',
slug: 'verify-email',
description: 'Verify email addresses instantly. Check DNS, MX records, typos, and disposable domains.',
features: ['DNS Validation', 'Typo Detection', 'MX Records', 'Disposable Check'],
icon: Search,
},
];
/**
* Free Email Tools index page
*/
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."
canonical="https://www.useplunk.com/tools"
openGraph={{
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
description:
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
url: 'https://www.useplunk.com/tools',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<Wrench className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Free tools for
<br />
email developers
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Build better emails with our free tools. Convert markdown to email-safe HTML, verify email addresses, and
more. No sign-up required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/guides"
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Browse guides
</Link>
</div>
</motion.div>
</section>
{/* Tools Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Available Tools</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to work with emails</p>
</motion.div>
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-2 max-w-4xl mx-auto'}>
{tools.map((tool, index) => {
const Icon = tool.icon;
return (
<Link key={tool.slug} href={`/tools/${tool.slug}`}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer h-full'
}
>
<div className={'flex items-start justify-between mb-4'}>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Icon className="h-5 w-5" />
</div>
<Sparkles className="h-5 w-5 text-neutral-400" />
</div>
<h3 className={'text-2xl font-bold text-neutral-900 mb-3'}>{tool.name}</h3>
<p className={'mb-6 leading-relaxed text-neutral-600'}>{tool.description}</p>
<div className={'space-y-2'}>
{tool.features.map(feature => (
<div key={feature} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>{feature}</span>
</div>
))}
</div>
</motion.div>
</Link>
);
})}
</div>
</section>
{/* Why Use These Tools */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why use these tools?</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Mail className="h-8 w-8 text-blue-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Free Forever</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
No sign-up, no paywalls, no limits. Use our tools as much as you need, completely free. We believe in
giving back to the email development community.
</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={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Code2 className="h-8 w-8 text-green-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Developer-Focused</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Built by developers who work with email every day. Clean outputs, instant results, and designed for
real-world email workflows.
</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={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Sparkles className="h-8 w-8 text-yellow-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Production Ready</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Generate email-safe HTML that works across all email clients. Verify emails with industry-standard
checks. Our tools are battle-tested and used by thousands of developers.
</p>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Need production-grade email tools?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
These free tools are great for development, but Plunk offers so much more: templates, scheduling,
automation, analytics, and deliverability optimization. Start free, scale as you grow.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start with Plunk
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,164 @@
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useMemo, useState} from 'react';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, Copy, Sparkles} from 'lucide-react';
import {MarkdownEmailEditor} from '../../components/tools/MarkdownEmailEditor';
import {convertToCompleteEmailHtml} from '../../lib/emailHtmlConverter';
import {Button} from '@plunk/ui';
export default function MarkdownToEmail() {
const [editorContent, setEditorContent] = useState('<p>Hello!</p><p>Try editing this text...</p>');
const [copied, setCopied] = useState(false);
// Convert editor content to complete, ready-to-send email HTML
const emailSafeHtml = useMemo(() => {
return convertToCompleteEmailHtml(editorContent);
}, [editorContent]);
const handleCopy = async () => {
await navigator.clipboard.writeText(emailSafeHtml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
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."
canonical="https://www.useplunk.com/tools/markdown-to-email"
openGraph={{
title: 'Free Markdown to Email HTML Converter | Plunk',
description:
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
url: 'https://www.useplunk.com/tools/markdown-to-email',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<Sparkles className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Tool</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Markdown to Email
<br />
HTML Converter
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Create beautiful, email-safe HTML instantly. Format your text with our visual editor and get
production-ready HTML with inlined styles that works across all email clients.
</p>
</motion.div>
</section>
{/* Editor Section */}
<section className={'py-16'}>
<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={'grid gap-6 lg:grid-cols-2'}
>
{/* Left: Editor */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900">Visual Editor</h2>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Code2 className="h-4 w-4" />
<span>Format your content</span>
</div>
</div>
<MarkdownEmailEditor value={editorContent} onChange={setEditorContent} />
</div>
{/* Right: Email-Safe HTML Output */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900">Email-Safe HTML</h2>
<Button onClick={handleCopy} size="sm" variant="outline" className="gap-2">
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<div className="border border-neutral-200 rounded-lg overflow-hidden bg-white">
<pre className="p-4 text-xs font-mono overflow-x-auto min-h-[500px] max-h-[500px] overflow-y-auto">
<code className="text-neutral-700">{emailSafeHtml}</code>
</pre>
</div>
</div>
</motion.div>
</section>
{/* CTA Section */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to send great emails?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
and deliverability. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start with Plunk
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,232 @@
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
import {ArrowRight, CheckCircle, Loader2, Search} from 'lucide-react';
import {type EmailVerificationResult as VerificationResult, verifyEmail} from '../../lib/emailVerification';
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
import {Button, Input} from '@plunk/ui';
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
export default function VerifyEmailPage() {
const [email, setEmail] = useState('');
const [result, setResult] = useState<VerificationResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setResult(null);
setLoading(true);
try {
const verificationResult = await verifyEmail(email);
setResult(verificationResult);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to verify email');
} finally {
setLoading(false);
}
};
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."
canonical="https://www.useplunk.com/tools/verify-email"
openGraph={{
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
url: 'https://www.useplunk.com/tools/verify-email',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<Search className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Tool</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Email Verification
<br />
Tool
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Verify email addresses instantly. Check for typos, disposable domains, DNS configuration, and more. Get
detailed verification results in seconds.
</p>
</motion.div>
</section>
{/* Verification Tool Section */}
<section className={'pb-16'}>
<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'}
>
{/* Input Form */}
<div className="rounded-lg border border-neutral-200 bg-white p-8 shadow-lg">
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-900 mb-2">
Email Address
</label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="[email protected]"
required
disabled={loading}
className="w-full"
/>
</div>
<Button type="submit" disabled={loading || !email} className="w-full gap-2">
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying...
</>
) : (
<>
<CheckCircle className="h-4 w-4" />
Verify Email
</>
)}
</Button>
</form>
{/* Error Display */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
</div>
{/* Results Display */}
{result && (
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5}}
className="mt-8"
>
<EmailVerificationResult result={result} />
</motion.div>
)}
</motion.div>
</section>
{/* Educational Content */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why verify email addresses?</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Email verification helps improve deliverability and protect your sender reputation.
</p>
</motion.div>
<div className={'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{EMAIL_VERIFICATION_FEATURES.map((feature, index) => {
const Icon = feature.icon;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<div
className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white mb-4'}
>
<Icon className="h-5 w-5" />
</div>
<h3 className={'text-xl font-semibold text-neutral-900 mb-2'}>{feature.title}</h3>
<p className={'text-sm text-neutral-600 leading-relaxed'}>{feature.description}</p>
</motion.div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Ready for production-grade email verification?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
This tool is great for testing individual emails, but Plunk offers bulk verification, real-time
validation, and seamless integration with your email workflows. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start with Plunk
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,431 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Workflow, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Expensive tiers'},
{feature: 'Setup Complexity', plunk: '5 minutes', competitor: 'Hours to days'},
{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: true},
{feature: 'CRM Included', plunk: false, competitor: true},
{feature: 'Sales Automation', plunk: false, competitor: true},
{feature: 'Machine Learning', plunk: false, competitor: true},
];
const faqs: FAQ[] = [
{
question: 'When should I choose ActiveCampaign over Plunk?',
answer:
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity—perfect for developers who want to integrate email into their product without CRM overhead.',
},
{
question: 'What is the price difference between Plunk and ActiveCampaign?',
answer:
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing—you only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
},
{
question: 'Can Plunk match ActiveCampaign\'s automation capabilities?',
answer:
'For email automation, yes. Plunk supports workflow automation, event-based triggers, and audience segmentation just like ActiveCampaign. The difference is Plunk focuses purely on email, while ActiveCampaign includes CRM, sales automation, and ML-powered recommendations. If you need email automation without sales/CRM features, Plunk delivers the same capability with simpler implementation.',
},
{
question: 'Is migration from ActiveCampaign to Plunk difficult?',
answer:
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward—export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
},
{
question: 'What ActiveCampaign features does Plunk not have?',
answer:
'Plunk focuses on email, so it doesn\'t include ActiveCampaign\'s CRM, sales pipeline management, lead scoring, SMS marketing, or machine learning features. If you need those, ActiveCampaign is better. If you want powerful email automation without the enterprise complexity, Plunk is the simpler choice.',
},
];
/**
* Plunk vs ActiveCampaign comparison page
*/
export default function ActiveCampaignComparison() {
return (
<>
<NextSeo
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
canonical="https://www.useplunk.com/vs/activecampaign"
openGraph={{
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
description:
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
url: 'https://www.useplunk.com/vs/activecampaign',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ActiveCampaign</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for ActiveCampaign
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
ActiveCampaign is powerful but expensive and complex. Plunk delivers essential email automation without
CRM bloat, sales features, or enterprise pricing. Built for developers, not marketing departments.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Enterprise Features Without Enterprise Prices
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Simple pay-as-you-go</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent. All features included, no tier gating, no forced upgrades.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All automation features included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimums or contracts</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Typical savings: 50-80% vs ActiveCampaign</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>ActiveCampaign</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Expensive subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Complex pricing with feature gating. Automation requires higher tiers.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $29/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Advanced automation: $149+/month</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Feature limits on lower tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Includes CRM/sales features you may not need</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Automation Without Complexity
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Affordable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go with all features included. No expensive tier upgrades required for automation. Typically
50-80% cheaper than ActiveCampaign.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in minutes. No CRM to configure, no sales pipeline to set up. Just authenticate and build
your email workflows.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean API designed for developers, not marketing teams. Integrate email into your product without
learning complex enterprise software.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full transparency into how your emails are processed. Contribute features,
understand the system. ActiveCampaign is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your own infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in.
ActiveCampaign is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Event-based triggers, segmentation, and automated sequences. All the email automation power of
ActiveCampaign without CRM complexity.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Get email automation without the bloat
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused email automation over enterprise complexity. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+431
View File
@@ -0,0 +1,431 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Globe, Mail, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Trial then subscription'},
{feature: 'Target Audience', plunk: 'Developers', competitor: 'Small businesses/creators'},
{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: true},
{feature: 'CRM Features', plunk: false, competitor: true},
{feature: 'Live Chat', plunk: false, competitor: true},
{feature: 'API-First Design', plunk: true, competitor: 'Integration-focused'},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Bento over Plunk?',
answer:
'Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you\'re a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.',
},
{
question: 'What is the pricing difference between Plunk and Bento?',
answer:
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed—you only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
},
{
question: 'Can Plunk handle email automation like Bento?',
answer:
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns—all the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
},
{
question: 'How does the developer experience compare?',
answer:
'Plunk is API-first, designed specifically for developers who want to integrate email into their applications. Bento is integration-focused, designed for small businesses using tools like Shopify and WordPress. Plunk gives developers more control and flexibility, while Bento offers pre-built integrations and a UI-first approach.',
},
{
question: 'Is migration from Bento to Plunk easy?',
answer:
'For email features, yes. Export your contacts from Bento, import them to Plunk, recreate your workflows using our API or dashboard, and integrate Plunk into your application. If you rely heavily on Bento\'s CRM, live chat, or multi-channel features, you\'ll need separate tools for those. Most email-only migrations take a few hours.',
},
];
/**
* Plunk vs Bento comparison page
*/
export default function BentoComparison() {
return (
<>
<NextSeo
title="Bento Alternative: Developer-First Email Platform | Plunk"
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
canonical="https://www.useplunk.com/vs/bento"
openGraph={{
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
description:
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
url: 'https://www.useplunk.com/vs/bento',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Bento</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Bento
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Bento is an all-in-one platform. Plunk is focused on email. Get powerful email automation without CRM,
live chat, or multi-channel complexity. Developer-first, API-driven, transparent pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Focused Email vs All-in-One Complexity
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go for email only. No CRM or chat features you don't need. Simple, transparent pricing.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails sent</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly commitments</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Focus on email, nothing more</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Bento</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Trial then subscription</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
30-day unlimited trial, then subscription pricing. Includes CRM, chat, and multi-channel features.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>30-day trial</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Trial ends, then monthly subscription</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Includes features beyond email</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>All-in-one platform approach</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Developer-First Email Platform
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First Design</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Built for developers who integrate email into their applications. Clean REST API, comprehensive docs,
webhook support.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full code transparency, contribute features, understand the system. Bento is
proprietary closed-source.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent. No subscriptions, no trials that expire, no forced plan upgrades. Predictable
costs based on usage.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in 5 minutes. No CRM to configure, no multi-channel setup, no live chat integration. Just
email, done right.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in.
Bento is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Mail className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Email-Focused</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Do one thing exceptionally well: email. No feature bloat, no CRM complexity, no multi-channel
confusion. Just powerful email automation.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="Bento" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Get focused email automation
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+425
View File
@@ -0,0 +1,425 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go per email', competitor: 'Monthly tiers by contacts'},
{feature: 'Contact Limits', plunk: 'Unlimited', competitor: 'Tier-based limits'},
{feature: 'Open Source', plunk: true, competitor: false},
{feature: 'Self-Hostable', plunk: true, competitor: false},
{feature: 'Target Audience', plunk: 'Developers & SaaS', competitor: 'All-in-one marketing suite'},
{feature: 'Setup Time', plunk: '5 minutes', competitor: '30+ minutes'},
{feature: 'Transactional Emails', plunk: true, competitor: true},
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
{feature: 'Workflow Automation', plunk: 'Included', competitor: 'Higher tiers only'},
];
const faqs: FAQ[] = [
{
question: 'What is the difference between Plunk and Brevo (Sendinblue)?',
answer:
'Plunk is built for developers with a simple API and pay-as-you-go pricing, while Brevo is an all-in-one marketing suite with more features but higher complexity. Plunk is open-source and self-hostable, while Brevo is proprietary. Plunk charges per email sent with unlimited contacts, while Brevo uses tiered pricing based on contact count.',
},
{
question: 'Is Plunk cheaper than Brevo?',
answer:
"It depends on your usage pattern. Plunk's pay-as-you-go model ($0.001 per email) with unlimited contacts is often more cost-effective for businesses with large contact lists but moderate email volume. Brevo charges based on contact count, which can become expensive as your audience grows.",
},
{
question: 'Can I self-host Plunk unlike Brevo?',
answer:
'Yes. Plunk is open-source (AGPL-3.0) and can be self-hosted using Docker, giving you full control over your data and infrastructure. Brevo is a proprietary cloud-only service with no self-hosting option.',
},
{
question: 'Is Plunk easier to use than Brevo?',
answer:
'For developers, yes. Plunk focuses on email essentials with a clean API and minimal setup. Brevo offers more features (SMS, chat, CRM) which adds complexity. If you just need email automation, Plunk is simpler. If you need an all-in-one marketing platform, Brevo might be better.',
},
{
question: 'How easy is it to migrate from Brevo to Plunk?',
answer:
"Migration is straightforward. Export your contacts from Brevo, import them to Plunk via CSV, and update your application to use Plunk's API. Most migrations take less than a day. Plunk's API is well-documented and easy to integrate.",
},
];
/**
* Plunk vs Brevo (Sendinblue) comparison page
*/
export default function BrevoComparison() {
return (
<>
<NextSeo
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
canonical="https://www.useplunk.com/vs/brevo"
openGraph={{
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
description:
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
url: 'https://www.useplunk.com/vs/brevo',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Brevo</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Brevo
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Simpler pricing, cleaner API, open-source code. All the email power, none of the bloat. Built for
developers, not marketing teams.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pricing That Scales With You</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple pay-as-you-go pricing. Unlimited contacts, pay only for emails sent.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>$0.001/email</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features on all plans</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimums</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Brevo</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Tiered by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription tiers based on contact count with feature restrictions on lower plans.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Contact-based</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pricing increases with contacts</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Features gated by tier</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Monthly subscription required</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Brevo</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simpler Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay $0.001 per email with unlimited contacts. No contact-based tiers, no surprise charges as you grow.
Predictable costs.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean API, clear documentation, 5-minute setup. Built for developers who want to ship fast, not navigate
complex marketing suites.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. Full transparency, no vendor
lock-in. Brevo is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Brevo is
cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts at no extra cost. Brevo charges more as your contact list grows. With Plunk,
you only pay for emails sent.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Email-Focused</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Plunk does email and does it well. No SMS, chat, CRM bloat. If you need just email automation, Plunk is
simpler and more focused.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<ComparisonTable competitorName="Brevo" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch to simplicity</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+429
View File
@@ -0,0 +1,429 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay per email', competitor: 'Pay per subscriber'},
{feature: 'Target Audience', plunk: 'Developers', competitor: 'Creators/marketers'},
{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: true},
{feature: 'Landing Pages', plunk: false, competitor: true},
{feature: 'Product Selling', plunk: false, competitor: true},
{feature: 'API-First Design', plunk: true, competitor: false},
];
const faqs: FAQ[] = [
{
question: 'When should I choose ConvertKit over Plunk?',
answer:
"Choose ConvertKit if you're a creator who needs landing pages, product selling, and a creator-focused interface designed for non-technical users. ConvertKit excels at helping course creators, bloggers, and podcasters monetize their audience. Choose Plunk if you're a developer who values API-first design, needs transactional + marketing emails in one platform, and prefers code over visual builders.",
},
{
question: 'What is the pricing difference between Plunk and ConvertKit?',
answer:
'ConvertKit charges based on subscriber count—your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
},
{
question: 'Can Plunk handle creator workflows like ConvertKit?',
answer:
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features—it's focused purely on email delivery and automation.",
},
{
question: 'Does Plunk have landing pages like ConvertKit?',
answer:
"No, Plunk doesn't include landing page or form builders. It's focused on being the best email platform for developers. If you need landing pages, you'd build them with your preferred tools and connect them to Plunk's API. This gives you full design control and keeps Plunk lightweight and developer-friendly.",
},
{
question: 'How easy is it to migrate from ConvertKit to Plunk?',
answer:
'Migration is straightforward. Export your subscribers from ConvertKit as CSV, import them to Plunk via our API or dashboard, recreate your email sequences using our workflow builder, and update your forms to use Plunk\'s API. Most migrations can be completed in a few hours. You\'ll need to rebuild landing pages separately if you rely on ConvertKit\'s landing page feature.',
},
];
/**
* Plunk vs ConvertKit comparison page
*/
export default function ConvertkitComparison() {
return (
<>
<NextSeo
title="ConvertKit Alternative for Developers | Plunk"
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation—no landing page bloat."
canonical="https://www.useplunk.com/vs/convertkit"
openGraph={{
title: 'ConvertKit Alternative for Developers | Plunk',
description:
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
url: 'https://www.useplunk.com/vs/convertkit',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ConvertKit</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for ConvertKit
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
ConvertKit is built for creators. Plunk is built for developers who need powerful email automation without
the creator-focused bloat. Same automation, better DX, pay-as-you-go.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Pay for Emails, Not Subscribers
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you send, not for subscribers you store.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited subscribers at no cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay when you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Grow your list without price increases</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>ConvertKit</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on total subscribers, whether you email them or not.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $25/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Price increases with subscriber count</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay even if you don't send emails</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>10K subscribers = $119/month</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Built for Developers, Not Creators</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First API</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern REST API designed for developers. No visual builder complexity, just clean endpoints for sending
emails and managing workflows.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent, not subscribers stored. Grow your list without worrying about tier upgrades or
subscription increases.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent.
ConvertKit is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your own infrastructure with Docker. Full data control, compliance-ready, cost-optimized for
scale. ConvertKit is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending emails in minutes. No form builders to configure, no landing pages to design. Just
authenticate and send.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts without penalty. No subscriber tiers, no forced upgrades. Your contact database
grows freely.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Ready for a developer-first email platform?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers who've switched from ConvertKit to Plunk for better API design and transparent
pay-as-you-go pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+421
View File
@@ -0,0 +1,421 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Code2, DollarSign, Globe, PackageOpen, Zap} 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: 'Limited free tier'},
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Complex usage-based tiers'},
{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: true},
{feature: 'Behavioral Segmentation', plunk: true, competitor: true},
{feature: 'Visual Journey Builder', plunk: true, competitor: true},
{feature: 'Developer-First API', plunk: true, competitor: false},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Customer.io over Plunk?',
answer:
"Choose Customer.io if you need advanced behavioral segmentation, visual journey builders, and have a dedicated marketing operations team. Customer.io excels at complex, enterprise-level marketing automation with sophisticated visual tools. Choose Plunk if you want developer-friendly APIs, simpler workflows, and don't need the overhead of an enterprise marketing platform.",
},
{
question: 'What is the pricing difference between Plunk and Customer.io?',
answer:
"Plunk uses a simple pay-as-you-go pricing model where you only pay for emails sent. Customer.io has complex usage-based tiers that combine messages sent, active profiles, and feature access. For small to medium teams, Plunk is typically more cost-effective. Customer.io's pricing scales with contact count and feature usage, which can become expensive as you grow.",
},
{
question: 'Is migration from Customer.io to Plunk complex?',
answer:
"Migration complexity depends on your current setup. If you use basic transactional emails and simple workflows, migration is straightforward - update your API integration and recreate your workflows. If you rely heavily on Customer.io's visual journey builder and advanced behavioral segmentation, you'll need to simplify some workflows. Most teams can migrate in a few days of development work.",
},
{
question: 'What does Plunk simplify compared to Customer.io?',
answer:
'Plunk removes enterprise complexity while keeping core functionality. You get a clean, developer-first API instead of complex visual builders. Workflows are defined in code or simple UI, not elaborate journey maps. Pricing is transparent pay-as-you-go instead of tiered complexity. Setup takes minutes instead of weeks. Plunk is open-source and self-hostable, giving you full control without vendor lock-in.',
},
];
/**
* Plunk vs Customer.io comparison page
*/
export default function CustomerioComparison() {
return (
<>
<NextSeo
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
canonical="https://www.useplunk.com/vs/customerio"
openGraph={{
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
description:
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
url: 'https://www.useplunk.com/vs/customerio',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Customer.io</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Customer.io
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Customer.io is powerful but complex. Plunk gives developers the automation they need without the
enterprise overhead.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Simple Pricing That Scales</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No contact-based pricing complexity</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features included, no upsells</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Customer.io</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Complex usage-based pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pricing based on profiles, messages, and feature tiers.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Tiered complexity</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay for profiles and messages sent</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Enterprise pricing can be expensive</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Advanced features locked behind tiers</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Key Advantages</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First Experience</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean, simple APIs designed for developers. No complex visual builders or marketing jargon. Integrate in
minutes, not weeks.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with no hidden costs or complex tiers. No need to pay for contact lists or profile
counts - just emails sent.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. No vendor lock-in with
proprietary platforms.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Quick Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Minutes to integrate and start sending. No lengthy onboarding, no sales calls, no enterprise setup
processes. Start free immediately.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hosting Option</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simplicity at Scale</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple workflows that scale without over-engineering. Get enterprise capabilities without enterprise
complexity or dedicated ops teams.
</p>
</motion.div>
</div>
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Developer-friendly from day one.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+324
View File
@@ -0,0 +1,324 @@
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code, DollarSign, Mail} from 'lucide-react';
const competitors = [
{
name: 'Postmark',
slug: 'postmark',
description: 'Transactional email reliability with marketing power',
features: ['Transactional Emails', 'Marketing Campaigns', 'Workflow Automation'],
},
{
name: 'SendGrid',
slug: 'sendgrid',
description: "Enterprise email platform vs Plunk's simplicity",
features: ['Transactional Emails', 'Marketing Campaigns', 'API-First Design'],
},
{
name: 'Mailgun',
slug: 'mailgun',
description: 'Developer-focused email vs open-source alternative',
features: ['Transactional Emails', 'Marketing Campaigns', 'SMTP Support'],
},
{
name: 'Mailchimp',
slug: 'mailchimp',
description: 'All-in-one marketing vs specialized email platform',
features: ['Marketing Campaigns', 'Transactional Emails', 'CRM Integration'],
},
{
name: 'Resend',
slug: 'resend',
description: 'Modern transactional email vs comprehensive solution',
features: ['Transactional Emails', 'Developer Experience', 'Marketing Ready'],
},
{
name: 'Customer.io',
slug: 'customerio',
description: 'Behavioral email vs workflow automation',
features: ['Behavioral Emails', 'Transactional Emails', 'Automation'],
},
{
name: 'Loops',
slug: 'loops',
description: 'Simple newsletter tool vs full email platform',
features: ['Newsletters', 'Transactional Emails', 'Simple Setup'],
},
{
name: 'Brevo',
slug: 'brevo',
description: 'European email platform vs global open-source',
features: ['Transactional Emails', 'Marketing Campaigns', 'GDPR Compliant'],
},
{
name: 'ConvertKit',
slug: 'convertkit',
description: 'Creator platform vs developer-first email',
features: ['Marketing Campaigns', 'Workflow Automation', 'API-First Design'],
},
{
name: 'ActiveCampaign',
slug: 'activecampaign',
description: 'Enterprise automation vs simple workflows',
features: ['Marketing Campaigns', 'Workflow Automation', 'Transactional Emails'],
},
{
name: 'MailerLite',
slug: 'mailerlite',
description: 'Developer-friendly vs developer-first platform',
features: ['Marketing Campaigns', 'Transactional Emails', 'Open Source'],
},
{
name: 'Klaviyo',
slug: 'klaviyo',
description: 'E-commerce leader vs affordable alternative',
features: ['E-commerce Integration', 'Workflow Automation', 'Pay-as-you-go'],
},
{
name: 'Bento',
slug: 'bento',
description: 'All-in-one platform vs focused email solution',
features: ['Transactional Emails', 'Marketing Campaigns', 'API-First'],
},
];
/**
* Plunk vs Competitors index page
*/
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."
canonical="https://www.useplunk.com/vs"
openGraph={{
title: 'Plunk vs Email Competitors | Compare Email Platforms',
description:
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
url: 'https://www.useplunk.com/vs',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Email Platform Comparisons</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Plunk vs the
<br />
competition
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Compare Plunk with leading email platforms. See why developers choose our open-source alternative for
transactional emails plus marketing features.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Competitors Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Compare Plunk with Industry Leaders
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
</motion.div>
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}>
{competitors.map((competitor, index) => (
<Link key={competitor.slug} href={`/vs/${competitor.slug}`}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer'
}
>
<div className={'mb-4 flex items-center justify-between'}>
<h3 className={'text-xl font-bold text-neutral-900'}>{competitor.name}</h3>
</div>
<p className={'mb-6 leading-relaxed text-neutral-600'}>{competitor.description}</p>
<div className={'mb-6 space-y-2'}>
{competitor.features.map(feature => (
<div key={feature} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>{feature}</span>
</div>
))}
</div>
</motion.div>
</Link>
))}
</div>
</section>
{/* Why Choose Plunk */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk</h2>
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Mail className="h-8 w-8 text-blue-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns,
automation, and segmentationall in one 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.2, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Code className="h-8 w-8 text-green-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and
infrastructure. No vendor lock-in.
</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={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<DollarSign className="h-8 w-8 text-yellow-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Simple Pricing</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with all features included. No separate charges for transactional vs marketing
emails. No tiers, no commitments.
</p>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to try Plunk?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+431
View File
@@ -0,0 +1,431 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Globe, Package, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Contact-based tiers'},
{feature: 'Typical Cost', plunk: 'Affordable', competitor: 'Very expensive'},
{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: true},
{feature: 'E-commerce Integration', plunk: 'API-based', competitor: 'Native deep'},
{feature: 'SMS Marketing', plunk: false, competitor: true},
{feature: 'Predictive Analytics', plunk: false, competitor: true},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Klaviyo over Plunk?',
answer:
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag—perfect for developers and growing businesses.',
},
{
question: 'How much cheaper is Plunk compared to Klaviyo?',
answer:
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing—you only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
},
{
question: 'Can Plunk handle e-commerce emails?',
answer:
'Yes. Plunk supports all e-commerce email use cases: transactional emails (order confirmations, shipping notifications), marketing campaigns (product launches, sales), and automated workflows (abandoned cart, post-purchase sequences). You integrate via API instead of native Shopify plugins, which gives you more flexibility and control over your email logic.',
},
{
question: 'Does Plunk integrate with Shopify like Klaviyo?',
answer:
'Plunk integrates with Shopify and other e-commerce platforms via API. While Klaviyo offers a native Shopify app with pre-built flows, Plunk gives you more control by letting you build exactly the integration you need. You can use webhooks, our API, or build custom integrations. This approach is more flexible but requires development work.',
},
{
question: 'What Klaviyo features does Plunk not have?',
answer:
'Plunk focuses on email, so it doesn\'t include Klaviyo\'s SMS marketing, native e-commerce platform integrations, AI-powered product recommendations, or predictive analytics. If you need those enterprise features and have the budget, Klaviyo is better. If you want powerful email automation at a fraction of the cost, Plunk delivers.',
},
];
/**
* Plunk vs Klaviyo comparison page
*/
export default function KlaviyoComparison() {
return (
<>
<NextSeo
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
canonical="https://www.useplunk.com/vs/klaviyo"
openGraph={{
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
description:
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
url: 'https://www.useplunk.com/vs/klaviyo',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Klaviyo</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Klaviyo
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers email automation for any use
caseincluding e-commerceat a fraction of the cost. No hidden fees, no contact-based pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
E-commerce Email Without the Premium Price
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails sent, not contacts stored. Predictable costs as you scale.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts at no extra cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No surprise charges as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Typical savings: 60-90% vs Klaviyo</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Klaviyo</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Contact-based tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Expensive pricing based on contact count. Costs escalate quickly as your list grows.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $20/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>10K contacts = $150-300+/month</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Price increases with contact list growth</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Hidden costs for additional features</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
E-commerce Email, Developer-Friendly
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Affordable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go instead of expensive contact-based tiers. Save 60-90% compared to Klaviyo while getting
the same email automation power.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Package className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>E-commerce Ready</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Handle order confirmations, shipping notifications, abandoned carts, and product campaigns. All
e-commerce email use cases via flexible API.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-Friendly</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern API designed for developers. Integrate with Shopify, WooCommerce, or any e-commerce platform via
webhooks and API calls.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full transparency into how your emails work. No black-box algorithms or proprietary
systems. Klaviyo is closed-source.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data ownership, compliance control, cost optimization at
scale. Klaviyo is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple Integration</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Connect your store via webhooks or API. No complex Shopify app to configure. Build exactly the
integration you need with full control.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
E-commerce email at a fraction of the cost
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join e-commerce developers who've switched from Klaviyo to Plunk for dramatic cost savings and better API
design.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+428
View File
@@ -0,0 +1,428 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Monthly subscription tiers'},
{feature: 'Open Source', plunk: true, competitor: false},
{feature: 'Self-Hostable', plunk: true, competitor: false},
{feature: 'Contact Limits', plunk: 'Unlimited', competitor: 'Tier-based limits'},
{feature: 'Transactional Emails', plunk: true, competitor: true},
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
{feature: 'Workflow Automation', plunk: true, competitor: true},
{feature: 'Dynamic Segmentation', plunk: true, competitor: true},
{feature: 'API Access', plunk: true, competitor: true},
];
const faqs: FAQ[] = [
{
question: 'What is the main difference between Plunk and Loops?',
answer:
'Plunk is open-source (AGPL-3.0) and self-hostable, while Loops is a proprietary SaaS platform. Plunk uses pay-as-you-go pricing with no contact limits, whereas Loops uses subscription tiers with contact-based limits. Both offer similar features, but Plunk gives you full transparency and control.',
},
{
question: 'Is Plunk cheaper than Loops?',
answer:
"It depends on your usage. Plunk's pay-as-you-go model ($0.001 per email) means you only pay for what you send. Loops charges monthly subscriptions based on contact count. For businesses with variable email volume or growing contact lists, Plunk is often more cost-effective.",
},
{
question: 'Can I self-host Plunk unlike Loops?',
answer:
'Yes. Plunk is open-source and can be self-hosted using Docker. This gives you full control over your data, infrastructure, and costs. Loops is cloud-only with no self-hosting option.',
},
{
question: 'Does Plunk have the same modern features as Loops?',
answer:
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API—just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
},
{
question: 'How easy is it to migrate from Loops to Plunk?',
answer:
"Migration is straightforward. Export your contacts from Loops, import them to Plunk via CSV, and update your application to use Plunk's API. Most migrations can be completed in a few hours.",
},
];
/**
* Plunk vs Loops comparison page
*/
export default function LoopsComparison() {
return (
<>
<NextSeo
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
canonical="https://www.useplunk.com/vs/loops"
openGraph={{
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
description:
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
url: 'https://www.useplunk.com/vs/loops',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Loops</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Loops
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Same modern features, but open-source and self-hostable. No contact limits, no proprietary lock-in, no
hidden costs. Built for transparency.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Transparent Pricing vs Vendor Lock-In
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with unlimited contacts. No monthly commitments or contact-based limits.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>$0.001/email</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts at no extra cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features included on all plans</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Open-source and self-hostable</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Loops</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Subscription tiers by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on contact count with tier-based limits and feature restrictions.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Tiered pricing</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Contact-based pricing limits</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Feature limits on lower tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Proprietary, cloud-only platform</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Loops</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent.
No black boxes.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized for scale.
Loops is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
No contact-based limits. Grow your audience without worrying about tier upgrades or surprise charges.
Pay for emails, not 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.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Predictable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go per email. No surprise costs as you grow. No forced tier upgrades. No sales calls. Just
simple, transparent pricing.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Full API Access</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Complete API access on all plans. No feature restrictions, no "contact sales" for API access. Everything
documented and ready to use.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All Features Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Transactional emails, campaigns, workflows, segmentationall included. No artificial feature gating
based on your plan.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<ComparisonTable competitorName="Loops" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Switch to open source</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+422
View File
@@ -0,0 +1,422 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Mail, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay per email', competitor: 'Pay per contact'},
{feature: 'Target Audience', plunk: 'Developers', competitor: 'Marketers'},
{feature: 'API Quality', plunk: 'Modern REST', competitor: 'Legacy/complex'},
{feature: 'Open Source', plunk: true, competitor: false},
{feature: 'Self-Hostable', plunk: true, competitor: false},
{feature: 'Transactional Emails', plunk: 'Built-in', competitor: 'Mandrill (separate)'},
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
{feature: 'Event-Based Triggers', plunk: true, competitor: 'Limited'},
{feature: 'Developer Experience', plunk: 'Excellent', competitor: 'Marketing-first'},
{feature: 'Setup Time', plunk: '5 minutes', competitor: '1-2 hours'},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Mailchimp over Plunk?',
answer:
"Choose Mailchimp if you're a marketer who needs a drag-and-drop email builder and prefers a marketing-first interface. Mailchimp excels at visual design and non-technical users. Choose Plunk if you're a developer who values API-first design, wants transactional + marketing in one platform, and prefers code over drag-and-drop.",
},
{
question: 'What is the difference between Plunk and Mailchimp pricing?',
answer:
'Mailchimp charges per contact stored, regardless of how many emails you send. Plunk uses a pay-as-you-go model where you only pay for emails sent. This means with Mailchimp, your cost increases as your contact list grows, while with Plunk, you only pay when you actually send emails.',
},
{
question: 'Can Plunk handle both transactional and marketing emails?',
answer:
"Yes, that's one of Plunk's key advantages. Transactional emails (receipts, password resets) and marketing emails (newsletters, campaigns) are built into one platform. With Mailchimp, you need their separate Mandrill service for transactional emails, which adds complexity.",
},
{
question: "Is Plunk's API easier to use than Mailchimp's?",
answer:
'Yes, significantly. Plunk has a modern RESTful API designed for developers. Mailchimp\'s API is complex and marketing-focused, requiring you to understand concepts like "audiences," "campaigns," and "merge fields." Most developers find Plunk\'s API 10x easier to integrate.',
},
{
question: 'Does Plunk have a visual email builder like Mailchimp?',
answer:
'Plunk has a minimal email editor, most of our users manage their own templates using their own HTML. This gives developers full control and enables version control, testing, and reusability. If you need a drag-and-drop builder, Mailchimp is better. If you prefer code and want beautiful, responsive emails, Plunk is the better choice.',
},
];
/**
* Plunk vs Mailchimp comparison page
*/
export default function MailchimpComparison() {
return (
<>
<NextSeo
title="Mailchimp Alternative for Developers | Plunk"
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
canonical="https://www.useplunk.com/vs/mailchimp"
openGraph={{
title: 'Mailchimp Alternative for Developers | Plunk',
description:
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
url: 'https://www.useplunk.com/vs/mailchimp',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailchimp</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Mailchimp
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Mailchimp is for marketers. Plunk is for developers who need full control, modern API, and transparent
pay-as-you-go pricing. Same features, better DX.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight 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 for what you store</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send, not for contacts stored.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts in your database</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No penalties for growing your list</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Mailchimp</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per contact stored</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Charged for every contact in your list, whether you email them or not.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay for contacts even if you don't email them</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Growing your list = automatic price increase</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Duplicate contacts count multiple times</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Developers Choose Plunk</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails you send, not for contacts you store. No monthly minimums or fixed subscription
costs.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern REST API designed for developers. 10x easier to integrate than Mailchimp's marketing-focused API.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in minutes. No audiences to configure, no lists to manage. Just send emails.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, self-host, no vendor lock-in. Mailchimp is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Mail className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Transactional and marketing emails in one platform. No need for separate Mandrill account.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<ArrowRight className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Event-Driven</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Trigger workflows based on user actions. Advanced automation that Mailchimp can't match.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Ready for a better developer experience?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers who've switched from Mailchimp to Plunk for better DX and transparent pay-as-you-go
pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+431
View File
@@ -0,0 +1,431 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay per email', competitor: 'Pay per subscriber'},
{feature: 'Open Source', plunk: true, competitor: false},
{feature: 'Self-Hostable', plunk: true, competitor: false},
{feature: 'Free Tier', plunk: '1,000 emails/month', competitor: '1,000 subscribers'},
{feature: 'Transactional Emails', plunk: true, competitor: true},
{feature: 'Marketing Campaigns', plunk: true, competitor: true},
{feature: 'Workflow Automation', plunk: true, competitor: true},
{feature: 'Drag-and-Drop Builder', plunk: 'Basic', competitor: 'Advanced'},
{feature: 'API Quality', plunk: 'Modern REST', competitor: 'Good REST'},
{feature: 'Developer Experience', plunk: 'Excellent', competitor: 'Good'},
];
const faqs: FAQ[] = [
{
question: 'What is the main difference between Plunk and MailerLite?',
answer:
'The main difference is that Plunk is open-source and self-hostable, while MailerLite is proprietary SaaS. Plunk uses pay-per-email pricing versus MailerLite\'s pay-per-subscriber model. Both are developer-friendly, but Plunk is API-first with full code transparency, while MailerLite focuses more on visual email builders with good API support.',
},
{
question: 'Is Plunk more expensive than MailerLite?',
answer:
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability—Plunk only charges for actual usage.',
},
{
question: 'Can I self-host Plunk unlike MailerLite?',
answer:
'Yes. Plunk is open-source (AGPL-3.0) and can be self-hosted using Docker. This gives you full control over your data, infrastructure costs, and compliance requirements. MailerLite is cloud-only with no self-hosting option. Self-hosting is ideal for privacy-sensitive use cases or cost optimization at scale.',
},
{
question: 'Does Plunk have a drag-and-drop builder like MailerLite?',
answer:
'Plunk has a basic email editor, but most users manage templates using HTML/code for full control, version control, and reusability. MailerLite excels at visual drag-and-drop building for non-technical users. If you\'re a developer who prefers code-based templates, Plunk is better. If you need rich visual builders for marketers, MailerLite is better.',
},
{
question: 'How does Plunk\'s API compare to MailerLite?',
answer:
'Both have modern REST APIs, but Plunk\'s API is designed with developers as the primary user. Plunk prioritizes API-first workflows, making it easier to integrate email into your application. MailerLite has a good API but is built UI-first with API as secondary. For programmatic email sending and automation, Plunk offers a superior developer experience.',
},
];
/**
* Plunk vs MailerLite comparison page
*/
export default function MailerliteComparison() {
return (
<>
<NextSeo
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
canonical="https://www.useplunk.com/vs/mailerlite"
openGraph={{
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
description:
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
url: 'https://www.useplunk.com/vs/mailerlite',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs MailerLite</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for MailerLite
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
MailerLite is developer-friendly. Plunk is developer-FIRST. Open-source, self-hostable, with API-first
design and pay-as-you-go pricing instead of subscriber tiers.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Open Source Meets Developer Experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Open-source and self-hostable for full control.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts, no subscriber fees</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Self-host or use our cloud</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Full code transparency (AGPL-3.0)</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>MailerLite</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on total subscribers. Proprietary cloud-only platform.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $10/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pricing based on subscriber count</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Cloud-only, no self-hosting</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Proprietary closed-source code</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
The Truly Developer-First Alternative
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your platform works.
MailerLite is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First Design</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Built for developers from day one. Modern REST API with comprehensive documentation. Integrate email
into your product seamlessly.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay per Email</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent, not subscribers stored. Grow your contact list without worrying about tier
upgrades or price increases.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your infrastructure with Docker. Full data ownership, compliance control, no vendor lock-in.
MailerLite is cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Fast Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in 5 minutes. Simple authentication, clean API endpoints, comprehensive docs. No visual
builder learning curve.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts without additional cost. Your database grows freely, pricing stays
predictable based on emails sent.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
</motion.div>
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
Experience true developer-first email
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing open-source transparency and API-first design. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+419
View File
@@ -0,0 +1,419 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {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: 'Limited trial'},
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Tiered monthly 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: false},
{feature: 'Workflow Automation', plunk: true, competitor: false},
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
{feature: 'Email Validation', plunk: 'Basic', competitor: 'Advanced'},
{feature: 'Custom Domains', plunk: true, competitor: true},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Mailgun over Plunk?',
answer:
'Choose Mailgun if you need advanced email validation features or have very high volume transactional email needs (millions per day) and want a proven infrastructure provider. Mailgun has been around longer and has extensive deliverability tools. Choose Plunk if you need marketing campaigns, workflow automation, or want the flexibility to self-host (though Plunk also offers fully-managed hosting).',
},
{
question: 'What is the pricing difference between Plunk and Mailgun?',
answer:
"Plunk uses a simple pay-as-you-go pricing model where you pay only for emails sent. Mailgun uses tiered monthly plans based on email volume, which can be cost-effective at very high volumes but less flexible for variable sending patterns. With Plunk, you get marketing campaigns and workflow automation included at no additional cost - with Mailgun, you'd need separate tools for marketing.",
},
{
question: 'Is migration from Mailgun to Plunk complex?',
answer:
"Migration requires updating your API integration since Plunk and Mailgun use different API structures. You'll need to update your code to use Plunk's endpoints and parameter format. Both platforms support similar core features (templates, webhooks, custom domains), so the concepts translate directly. Plan for a few hours of development work to migrate your integration. Your email templates can be adapted with minimal changes.",
},
{
question: 'What does Plunk add beyond transactional emails?',
answer:
'Plunk adds marketing campaigns (one-time broadcasts to all contacts or specific segments), workflow automation (multi-step email sequences with triggers, delays, and conditions), and dynamic audience segmentation (auto-updating groups based on contact data and behavior). These features mean you can handle both transactional and marketing emails in one platform. Plunk is also open-source (AGPL-3.0) and self-hostable, giving you full control over your email infrastructure.',
},
];
/**
* Plunk vs Mailgun comparison page
*/
export default function MailgunComparison() {
return (
<>
<NextSeo
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
canonical="https://www.useplunk.com/vs/mailgun"
openGraph={{
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
description:
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
url: 'https://www.useplunk.com/vs/mailgun',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailgun</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Mailgun
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Mailgun is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
open-source.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight 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-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Scale up or down without commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing and automation included</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Mailgun</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Tiered monthly plans</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription with tiered email volume limits.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed tiers</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Need to upgrade plan as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional only, no marketing</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
</section>
{/* What Plunk Adds */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance.
Mailgun doesn't offer 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Perfect for onboarding, drip
campaigns, cart abandonment.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Target campaigns
precisely.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Mailgun is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Layers className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
One platform for transactional, marketing, and automation. No need for multiple tools or integrations.
</p>
</motion.div>
</div>
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+423
View File
@@ -0,0 +1,423 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{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: 'Open Source', plunk: true, competitor: false},
{feature: 'Self-Hostable', plunk: true, competitor: false},
];
const faqs: FAQ[] = [
{
question: 'What is the main difference between Plunk and Postmark?',
answer:
'Postmark focuses exclusively on transactional emails, while Plunk offers transactional emails plus marketing campaigns, workflow automation, and dynamic segmentation. Plunk is also open-source and self-hostable, giving you more flexibility and control.',
},
{
question: 'Is Plunk as reliable as Postmark for transactional emails?',
answer:
'Yes. Plunk is built on AWS SES, which has excellent deliverability and reliability comparable to Postmark. Both support custom domains, DKIM authentication, and provide detailed delivery analytics. The key difference is Plunk adds marketing and automation features on top.',
},
{
question: 'Can I use Plunk for just transactional emails like Postmark?',
answer:
'Absolutely. You can use Plunk purely for transactional emails just like Postmark. The marketing campaigns and workflows are optional features that are available when you need them, at no extra cost.',
},
{
question: 'Why should I choose Plunk over Postmark?',
answer:
'Choose Plunk if you want transactional emails plus marketing capabilities in one platform. You get campaigns, automation, and segmentation without paying for a second service. Plunk is also open-source, so you can self-host or inspect the code.',
},
{
question: 'How easy is it to migrate from Postmark to Plunk?',
answer:
"Migration is straightforward. Plunk's API is simple and well-documented. Update your application to use Plunk's API endpoints, configure your custom domain, and you're ready to go. Most migrations take a few hours.",
},
];
/**
* Plunk vs Postmark comparison page
*/
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."
canonical="https://www.useplunk.com/vs/postmark"
openGraph={{
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
description:
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
url: 'https://www.useplunk.com/vs/postmark',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Postmark</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Postmark
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Everything Postmark offers for transactional emails, plus marketing campaigns, workflow automation, and
segmentation. One platform, no extra cost.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>One Platform for All Your Emails</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Reliable transactional emails plus marketing campaigns, workflows, and segmentation in one platform.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>All-in-one</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Transactional emails included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing campaigns included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Workflow automation included</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Postmark</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Transactional only</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Excellent for transactional emails, but no marketing features. Need a second service for campaigns.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Limited scope</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional emails only</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>No marketing campaigns</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>No workflow automation</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Postmark</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send newsletters, announcements, and promotional emails without needing a separate marketing platform.
One platform, one bill.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads,
re-engage customersall automated.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Segment your audience based on behavior, properties, and engagement. Send targeted emails to 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how it works. No
proprietary black boxes.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Postmark is
cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-As-You-Go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple per-email pricing with all features included. No separate charges for transactional vs marketing
emails. No tiers, no commitments.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<ComparisonTable competitorName="Postmark" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get more from your email platform</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
free.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+419
View File
@@ -0,0 +1,419 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {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: '3,000 emails/month'},
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Fixed subscription'},
{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: false},
{feature: 'Workflow Automation', plunk: true, competitor: false},
{feature: 'Dynamic Segmentation', plunk: true, competitor: false},
{feature: 'Event Tracking', plunk: true, competitor: true},
{feature: 'Custom Domains', plunk: true, competitor: true},
];
const faqs: FAQ[] = [
{
question: 'When should I choose Resend over Plunk?',
answer:
'Choose Resend if you only need transactional emails and prefer a service focused exclusively on that use case. Resend has a larger free tier and excellent developer experience for transactional emails. Choose Plunk if you need marketing campaigns, automation workflows, or want the flexibility to self-host (though Plunk also offers fully-managed hosting).',
},
{
question: 'What is the pricing difference between Plunk and Resend?',
answer:
"Plunk uses a pay-as-you-go pricing model, while Resend uses a fixed subscription model. Both optimize for different use cases - Resend focuses exclusively on transactional emails. Plunk includes marketing campaigns and workflow automation at no additional cost. If you need both transactional and marketing emails, Plunk may be more cost-effective overall since you won't need a separate marketing platform.",
},
{
question: 'Is migration from Resend to Plunk complex?',
answer:
"Migration requires updating your API integration since Plunk and Resend use different API structures. You'll need to update your code to use Plunk's endpoints and parameter format. However, both platforms support similar features (templates, webhooks, custom domains), so the concepts translate directly. Plan for a few hours of development work to migrate your integration.",
},
{
question: "What does Plunk offer that Resend doesn't?",
answer:
'Plunk adds marketing campaigns (one-time broadcasts to segments), workflow automation (multi-step email sequences with triggers and conditions), and dynamic audience segmentation. These features mean you can handle both transactional and marketing emails in one platform. Plunk is also open-source (AGPL-3.0) and self-hostable, giving you full control over your email infrastructure.',
},
];
/**
* Plunk vs Resend comparison page
*/
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."
canonical="https://www.useplunk.com/vs/resend"
openGraph={{
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
description:
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
url: 'https://www.useplunk.com/vs/resend',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Resend</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for Resend
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Resend is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
open-source. Choose based on what you need.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight 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-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Scale up or down without commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing and automation included</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Resend</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Fixed subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription with fixed email limits per tier.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Need to upgrade plan as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional only, no marketing</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Resend" rows={comparisonData} />
</section>
{/* What Plunk Adds */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance. Resend
doesn't offer 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Perfect for onboarding, drip
campaigns, cart abandonment.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Target campaigns
precisely.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold 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. Resend is proprietary.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only 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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Layers className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
One platform for transactional, marketing, and automation. No need for multiple tools or integrations.
</p>
</motion.div>
</div>
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+425
View File
@@ -0,0 +1,425 @@
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
const comparisonData: ComparisonRow[] = [
{feature: 'Pricing Model', plunk: 'Pay-as-you-go', competitor: 'Monthly subscription tiers'},
{feature: 'Setup Time', plunk: '5 minutes', competitor: 'Hours of configuration'},
{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: 'Included', competitor: 'Separate product'},
{feature: 'Workflow Automation', plunk: true, competitor: 'Enterprise only'},
{feature: 'Dynamic Segmentation', plunk: true, competitor: 'Limited'},
{feature: 'API Simplicity', plunk: 'Modern REST', competitor: 'Complex legacy API'},
];
const faqs: FAQ[] = [
{
question: 'What is the difference between Plunk and SendGrid pricing?',
answer:
"Plunk uses a pay-as-you-go model where you only pay for emails sent, with no monthly minimums. SendGrid uses monthly subscription tiers with fixed costs regardless of actual usage. Plunk's approach means you're not locked into a monthly fee if your email volume varies.",
},
{
question: 'Why is SendGrid so complicated to set up?',
answer:
'SendGrid is built for large enterprises with complex needs. It requires configuring IP addresses, sender authentication, subuser accounts, and understanding their legacy API structure. Plunk is built for developers who want to start sending emails in minutes with a simple, modern API.',
},
{
question: 'Does Plunk have the same deliverability as SendGrid?',
answer:
'Yes. Plunk is built on top of AWS SES, which has excellent deliverability comparable to SendGrid. Both platforms support custom domains, DKIM authentication, and dedicated IPs (for high-volume senders). The key difference is Plunk makes these features accessible without the complexity.',
},
{
question: 'Can I migrate from SendGrid to Plunk?',
answer:
"Yes, migration is straightforward. Export your contacts from SendGrid, import them to Plunk via CSV, and update your application to use Plunk's easy API. Most migrations take less than a day.",
},
{
question: 'What SendGrid features does Plunk not have?',
answer:
'Plunk focuses on modern email workflows rather than enterprise-specific features. For 95% of use cases, Plunk provides everything you need.',
},
];
/**
* Plunk vs SendGrid comparison page
*/
export default function SendGridComparison() {
return (
<>
<NextSeo
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
canonical="https://www.useplunk.com/vs/sendgrid"
openGraph={{
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
description:
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
url: 'https://www.useplunk.com/vs/sendgrid',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs SendGrid</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Open-source alternative
<br />
for SendGrid
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Pay-as-you-go instead of fixed subscriptions. No complex setup, no feature gating, no enterprise sales
pitches. Built for developers, not procurement teams.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight 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 for what you might use</p>
</motion.div>
<div className={'grid gap-8 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-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send, no monthly minimums.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimum or commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing campaigns included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Workflow automation included</span>
</div>
</div>
</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-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>SendGrid</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Monthly subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Fixed monthly fees based on email volume tiers with feature restrictions.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription plan</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Marketing is a separate paid product</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Automation locked to enterprise tier</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Key Advantages */}
<section className={'py-32'}>
<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-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over SendGrid</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple, Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go per email. No hidden fees, no complex tiers, no enterprise sales calls. Start free, scale
as you grow.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending emails in minutes, not hours. Modern API, clear documentation, no complex configuration.
Copy-paste and go.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Campaigns, workflows, and segmentation at no extra cost. SendGrid requires their separate Marketing
Campaigns product with additional fees.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, self-host if needed. No black boxes, no vendor
lock-in.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. SendGrid is
cloud-only.
</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={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>No Feature Gating</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
All features available on all plans. No artificial limits, no forced upgrades to "enterprise" for basic
automation.
</p>
</motion.div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<ComparisonTable competitorName="SendGrid" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch today</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start free trial
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
siteUrl: process.env.NEXT_PUBLIC_DASHBOARD_URI || 'https://app.useplunk.com',
generateRobotsTxt: true,
};
+37 -2
View File
@@ -1,6 +1,7 @@
import {Badge, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import {Badge, Button, Collapsible, CollapsibleContent, CollapsibleTrigger} from '@plunk/ui';
import type {Activity} from './ActivityFeed';
import {memo} from 'react';
import {memo, useState} from 'react';
import {EmailPreviewModal} from './EmailPreviewModal';
import {
AlertCircle,
Calendar,
@@ -90,6 +91,13 @@ function getUpcomingTime(date: Date): string {
return `in ${diffInMonths} ${diffInMonths === 1 ? 'month' : 'months'}`;
}
/**
* Check if an activity is an email activity
*/
function isEmailActivity(type: string): boolean {
return ['email.sent', 'email.delivered', 'email.opened', 'email.clicked', 'email.bounced'].includes(type);
}
interface ActivityItemProps {
activity: Activity;
isUpcoming?: boolean;
@@ -295,6 +303,7 @@ function getActivityConfig(activity: Activity): ActivityConfig {
}
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
const [showPreviewModal, setShowPreviewModal] = useState(false);
const config = getActivityConfig(activity);
const Icon = config.icon;
const timestamp = new Date(activity.timestamp);
@@ -318,6 +327,17 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
{config.title}
</p>
{config.badge && <Badge variant={config.badge.variant}>{config.badge.label}</Badge>}
{isEmailActivity(activity.type) && activity.metadata.subject && activity.metadata.body ? (
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreviewModal(true)}
className="h-6 px-2 text-xs"
>
<Eye className="h-3 w-3 mr-1" />
Preview
</Button>
) : null}
</div>
{config.description && <p className="text-sm text-neutral-500 line-clamp-2">{config.description}</p>}
{activity.contactEmail && (
@@ -357,6 +377,21 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
</span>
</div>
</div>
{/* Email Preview Modal */}
{showPreviewModal && activity.metadata.subject && activity.metadata.body ? (
<EmailPreviewModal
open={showPreviewModal}
onOpenChange={setShowPreviewModal}
subject={String(activity.metadata.subject)}
body={String(activity.metadata.body)}
from={activity.metadata.from ? String(activity.metadata.from) : undefined}
fromName={activity.metadata.fromName ? String(activity.metadata.fromName) : undefined}
replyTo={activity.metadata.replyTo ? String(activity.metadata.replyTo) : undefined}
toName={activity.metadata.toName ? String(activity.metadata.toName) : undefined}
toEmail={activity.contactEmail}
/>
) : null}
</div>
);
});
@@ -38,9 +38,7 @@ export function HtmlEditor({value, onChange, placeholder}: HtmlEditorProps) {
backgroundColor: '#f3f4f6',
color: '#374151',
},
'.cm-activeLine': {
backgroundColor: '#f9fafb',
},
'.cm-selectionBackground, ::selection': {
backgroundColor: '#dbeafe !important',
},
@@ -117,7 +117,7 @@ export const VariableMention = Mention.configure({
items: ({query}) => {
const safeVariables = Array.isArray(availableVariables) ? availableVariables : [];
const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables];
const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', 'locale', ...safeVariables];
const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string');
if (!query) {
@@ -0,0 +1,561 @@
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
import {Monitor, Smartphone, Tablet} from 'lucide-react';
import {useState} from 'react';
interface EmailPreviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
subject: string;
body: string;
from?: string;
fromName?: string;
replyTo?: string;
toName?: string;
toEmail?: string;
}
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
const detectCustomHtmlPatterns = (html: string): boolean => {
if (!html || html.trim() === '') return false;
// Check for common patterns that indicate custom HTML
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
// Check for custom classes (exclude TipTap's generated classes)
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
let hasCustomClasses = false;
for (const match of classMatches) {
const classValue = match[1];
if (!classValue) continue;
// Split by whitespace to get individual classes
const classes = classValue.split(/\s+/).filter((c) => c.length > 0);
// Check if any class is NOT in the allowed list
const allowedPrefixes = [
'prose',
'variable-',
'email-image',
'ProseMirror',
'resizable-image',
'selected',
'resize-handle',
];
const hasDisallowedClass = classes.some((cls) => !allowedPrefixes.some((prefix) => cls.startsWith(prefix)));
if (hasDisallowedClass) {
hasCustomClasses = true;
break;
}
}
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
};
// Helper function to wrap email body with necessary styles for proper rendering
// Only wraps visual editor content - custom HTML is returned as-is
const wrapEmailWithStyles = (htmlBody: string): string => {
// If this is custom HTML (from HTML editor), return it as-is without wrapping
if (detectCustomHtmlPatterns(htmlBody)) {
return htmlBody;
}
// For visual editor content, wrap with prose styles
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* Base reset */
* { box-sizing: border-box; }
body {
margin: 0;
padding: 16px;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
color: #111827;
}
/* Tailwind Typography (prose) base styles */
.prose {
color: #374151;
max-width: 65ch;
}
.prose [class~="lead"] {
color: #4b5563;
font-size: 1.25em;
line-height: 1.6;
margin-top: 1.2em;
margin-bottom: 1.2em;
}
.prose a {
color: #3b82f6;
text-decoration: underline;
font-weight: 500;
}
.prose strong {
color: #111827;
font-weight: 600;
}
.prose ol, .prose ul {
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-left: 1.625em;
}
.prose li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose ol > li {
padding-left: 0.375em;
}
.prose ul > li {
padding-left: 0.375em;
}
.prose > ul > li p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose > ul > li > *:first-child {
margin-top: 1.25em;
}
.prose > ul > li > *:last-child {
margin-bottom: 1.25em;
}
.prose > ol > li > *:first-child {
margin-top: 1.25em;
}
.prose > ol > li > *:last-child {
margin-bottom: 1.25em;
}
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose hr {
border-color: #e5e7eb;
border-top-width: 1px;
margin-top: 3em;
margin-bottom: 3em;
}
.prose blockquote {
font-weight: 500;
font-style: italic;
color: #111827;
border-left-width: 0.25rem;
border-left-color: #e5e7eb;
quotes: "\\201C""\\201D""\\2018""\\2019";
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-left: 1em;
}
.prose h1 {
color: #111827;
font-weight: 800;
font-size: 2.25em;
margin-top: 0;
margin-bottom: 0.8888889em;
line-height: 1.1111111;
}
.prose h2 {
color: #111827;
font-weight: 700;
font-size: 1.5em;
margin-top: 2em;
margin-bottom: 1em;
line-height: 1.3333333;
}
.prose h3 {
color: #111827;
font-weight: 600;
font-size: 1.25em;
margin-top: 1.6em;
margin-bottom: 0.6em;
line-height: 1.6;
}
.prose h4 {
color: #111827;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
line-height: 1.5;
}
.prose img {
margin-top: 2em;
margin-bottom: 2em;
}
.prose figure {
margin-top: 2em;
margin-bottom: 2em;
}
.prose figure > * {
margin-top: 0;
margin-bottom: 0;
}
.prose code {
color: #111827;
font-weight: 600;
font-size: 0.875em;
}
.prose code::before {
content: "\`";
}
.prose code::after {
content: "\`";
}
.prose pre {
color: #e5e7eb;
background-color: #1f2937;
overflow-x: auto;
font-size: 0.875em;
line-height: 1.7142857;
margin-top: 1.7142857em;
margin-bottom: 1.7142857em;
border-radius: 0.375rem;
padding-top: 0.8571429em;
padding-right: 1.1428571em;
padding-bottom: 0.8571429em;
padding-left: 1.1428571em;
}
.prose pre code {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: 400;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose pre code::before {
content: none;
}
.prose pre code::after {
content: none;
}
.prose table {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.7142857;
border-collapse: collapse;
}
.prose thead {
border-bottom-width: 1px;
border-bottom-color: #d1d5db;
}
.prose thead th {
color: #111827;
font-weight: 600;
vertical-align: bottom;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose tbody tr {
border-bottom-width: 1px;
border-bottom-color: #e5e7eb;
}
.prose tbody tr:last-child {
border-bottom-width: 0;
}
.prose tbody td {
vertical-align: top;
padding-top: 0.5714286em;
padding-right: 0.5714286em;
padding-bottom: 0.5714286em;
padding-left: 0.5714286em;
}
.prose p {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
/* prose-sm modifier */
.prose-sm {
font-size: 0.875rem;
line-height: 1.7142857;
}
.prose-sm p {
margin-top: 1.1428571em;
margin-bottom: 1.1428571em;
}
.prose-sm h1 {
font-size: 2.1428571em;
margin-top: 0;
margin-bottom: 0.8em;
line-height: 1.2;
}
.prose-sm h2 {
font-size: 1.4285714em;
margin-top: 1.6em;
margin-bottom: 0.8em;
line-height: 1.4;
}
.prose-sm h3 {
font-size: 1.2857143em;
margin-top: 1.5555556em;
margin-bottom: 0.4444444em;
line-height: 1.5555556;
}
.prose-sm h4 {
margin-top: 1.4285714em;
margin-bottom: 0.5714286em;
line-height: 1.4285714;
}
.prose-sm img {
margin-top: 1.7142857em;
margin-bottom: 1.7142857em;
}
.prose-sm ol, .prose-sm ul {
margin-top: 1.1428571em;
margin-bottom: 1.1428571em;
padding-left: 1.5714286em;
}
.prose-sm li {
margin-top: 0.2857143em;
margin-bottom: 0.2857143em;
}
/* max-w-none utility */
.max-w-none {
max-width: none;
}
/* Custom editor styles */
.variable-highlight, .variable-placeholder, .variable-mention {
background-color: #dbeafe;
color: #1e40af;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
display: inline;
}
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
th, td {
border: 1px solid #e5e7eb;
padding: 8px 12px;
text-align: left;
min-width: 100px;
}
th {
background-color: #f3f4f6;
font-weight: 600;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 16px 0;
}
.resizable-image-wrapper {
display: block;
margin: 16px 0;
}
.resizable-image-container {
display: inline-block;
position: relative;
max-width: 100%;
}
.resizable-image-container img {
margin: 0;
}
</style>
</head>
<body>
<div class="prose prose-sm max-w-none">
${htmlBody}
</div>
</body>
</html>`;
};
export function EmailPreviewModal({
open,
onOpenChange,
subject,
body,
from,
fromName,
replyTo,
toName,
toEmail,
}: EmailPreviewModalProps) {
const [previewDevice, setPreviewDevice] = useState<PreviewDevice>('desktop');
const getPreviewContainerWidth = () => {
switch (previewDevice) {
case 'mobile':
return '375px';
case 'tablet':
return '768px';
case 'desktop':
return '100%';
}
};
const getDeviceLabel = () => {
switch (previewDevice) {
case 'mobile':
return '375px';
case 'tablet':
return '768px';
case 'desktop':
return '1200px';
}
};
const displayFrom = fromName ? `${fromName} <${from}>` : from;
// Wrap the body with styles for proper rendering
const styledBody = wrapEmailWithStyles(body);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl h-[90vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
<DialogTitle className="pr-8">Email Preview</DialogTitle>
</DialogHeader>
{/* Device Selector */}
<div className="border-t border-b border-neutral-200 bg-neutral-100 px-6 py-3 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<p className="text-xs font-medium text-neutral-600">Preview</p>
<span className="text-xs text-neutral-500">({getDeviceLabel()})</span>
</div>
<div className="flex gap-1">
<Button
type="button"
variant={previewDevice === 'mobile' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPreviewDevice('mobile')}
className="h-7 w-7 p-0"
title="Mobile (375px)"
>
<Smartphone className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={previewDevice === 'tablet' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPreviewDevice('tablet')}
className="h-7 w-7 p-0"
title="Tablet (768px)"
>
<Tablet className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant={previewDevice === 'desktop' ? 'default' : 'ghost'}
size="sm"
onClick={() => setPreviewDevice('desktop')}
className="h-7 w-7 p-0"
title="Desktop (1200px)"
>
<Monitor className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
{/* Preview Area - Single scroll container */}
<div className="flex-1 overflow-y-auto bg-neutral-50">
<div className="p-6 flex justify-center items-start min-h-full">
<div
className="transition-all duration-300"
style={{
width: getPreviewContainerWidth(),
maxWidth: '100%',
}}
>
<div className="bg-white rounded-lg border border-neutral-300 shadow-lg overflow-hidden">
{/* Email Header Preview */}
<div className="bg-neutral-50 border-b border-neutral-200 p-4 space-y-2">
{/* To Section */}
{(toName || toEmail) && (
<div>
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">To</p>
<p className="text-sm text-neutral-900 mt-1">
{toName && toEmail ? `${toName} <${toEmail}>` : toName || toEmail}
</p>
</div>
)}
{/* Subject Section */}
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-neutral-500 uppercase tracking-wide font-medium">Subject</p>
<p className="text-base font-semibold text-neutral-900 mt-1">{subject}</p>
</div>
</div>
{/* From and Reply-To Section */}
{(from || replyTo) && (
<div className="flex gap-6 pt-2 border-t border-neutral-200">
{from && (
<div>
<p className="text-xs text-neutral-500">From</p>
<p className="text-sm text-neutral-900 mt-0.5">{displayFrom}</p>
</div>
)}
{replyTo && (
<div>
<p className="text-xs text-neutral-500">Reply-To</p>
<p className="text-sm text-neutral-900 mt-0.5">{replyTo}</p>
</div>
)}
</div>
)}
</div>
{/* Email Body */}
<div className="bg-white">
<iframe
sandbox="allow-same-origin"
srcDoc={styledBody}
className="w-full border-0 block"
style={{minHeight: '500px'}}
title="Email preview"
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -8,28 +8,40 @@ interface SecurityWarningBannerProps {
}
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
// Don't show if no warnings or already disabled
if (status.warnings.length === 0 || status.shouldDisable) {
// Don't show if no warnings or violations
const hasCriticalViolations = status.violations.length > 0;
const hasWarnings = status.warnings.length > 0;
if (!hasCriticalViolations && !hasWarnings) {
return null;
}
// Determine severity - critical violations take precedence
const variant = hasCriticalViolations ? 'destructive' : 'warning';
const title = hasCriticalViolations
? 'Critical Security Violations - Immediate Action Required'
: 'Security Warning - Action Required';
const issues = hasCriticalViolations ? status.violations : status.warnings;
const messageColor = hasCriticalViolations ? 'text-red-800' : 'text-amber-800';
return (
<Alert variant="warning">
<Alert variant={variant}>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Security Warning - Action Required</AlertTitle>
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-2 flex-1">
<p className="text-sm font-medium">
Your project has exceeded the following security thresholds:
</p>
<ul className="list-disc list-inside space-y-1 text-sm text-amber-800">
{status.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
<ul className={`list-disc list-inside space-y-1 text-sm ${messageColor}`}>
{issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
<p className="text-xs text-amber-800 mt-2">
High bounce or complaint rates can lead to project suspension. Review the detailed metrics
and take action to improve your email quality.
<p className={`text-xs ${messageColor} mt-2`}>
{hasCriticalViolations
? 'Your project may be suspended soon. Please review the detailed metrics immediately and take action to improve your email quality.'
: 'High bounce or complaint rates can lead to project suspension. Review the detailed metrics and take action to improve your email quality.'}
</p>
</div>
<Link href="/settings?tab=security">
@@ -100,9 +100,11 @@ function useAvailableOptions() {
category: 'Email Activity' as const,
});
} else {
// Ensure event has the 'event.' prefix for backend compatibility
const eventValue = name.startsWith('event.') ? name : `event.${name}`;
eventOptions.push({
value: name,
label: name,
value: eventValue,
label: name.replace(/^event\./, ''), // Remove prefix from label for display
type: 'event' as const,
category: 'Events' as const,
});

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