Compare commits

..
176 Commits
Author SHA1 Message Date
ishan-karmakar 2700e62935 Remove unnecessary landing page port expose
CI / Test Suite (push) Has been cancelled
CI / Lint & Type Check (push) Has been cancelled
Docker Build and Publish / prepare (push) Has been cancelled
Docker Build and Publish / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker Build and Publish / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Build and Publish / merge (push) Has been cancelled
Release Please / release-please (push) Has been cancelled
2026-06-06 10:51:50 -05:00
ishan-karmakar 517b93cc95 Update docker-compose for our purposes
CI / Test Suite (push) Waiting to run
CI / Lint & Type Check (push) Waiting to run
Docker Build and Publish / prepare (push) Waiting to run
Docker Build and Publish / build (linux/amd64, ubuntu-latest) (push) Blocked by required conditions
Docker Build and Publish / build (linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Docker Build and Publish / merge (push) Blocked by required conditions
Release Please / release-please (push) Waiting to run
2026-06-06 10:25:13 -05:00
Dries Augustyns 8e3fb9595d tests: prevent race condition by inserting RUNNING execution directly 2026-05-27 18:14:29 +02:00
Dries Augustyns 58be4abc31 tests: prevent race condition by inserting RUNNING execution directly 2026-05-27 18:08:13 +02:00
Dries Augustyns 480f0c1034 tests: refactor request logger tests to use helper functions for async logging verification 2026-05-27 18:01:56 +02:00
Dries Augustyns 80beb2bb99 feat(EmailService): add worker concurrency settings and improve email queue prioritization 2026-05-27 17:53:30 +02:00
Dries Augustyns 6ab4d77ca9 feat(SecurityService): enhance phishing detection by verifying sender domains and institutional TLDs 2026-05-25 18:16:07 +02:00
Dries Augustyns edfc399061 feat(SecurityService): enhance phishing detection by verifying sender domains and institutional TLDs 2026-05-25 18:04:52 +02:00
Dries AugustynsandGitHub 4a145f3488 Merge pull request #391 from andygrunwald/andygrunwald/csv-import-coerce-custom-field-types
fix: coerce boolean and numeric values in custom CSV columns
2026-05-25 07:30:50 +02:00
Andy Grunwald 868bdee615 import-processor: Reworked comments for coerceCustomValue 2026-05-24 16:15:57 +02:00
Dries AugustynsandGitHub 6ebbb50f68 Merge pull request #393 from andygrunwald/andygrunwald/sync-env-vars-and-add-process-rule
docs(env): sync env example files, fix CLAUDE.md drift, add process rule
2026-05-24 16:07:11 +02:00
Andy Grunwald 87eb56ff36 Revert "docs(env): sync .env.self-host.example with missing variables"
This reverts commit 1c1c95d332.
2026-05-24 16:05:25 +02:00
Andy GrunwaldandClaude Opus 4.7 a348d37c21 docs: add env-var sync rule to CLAUDE.md
Plunk has three sources of truth for environment variables:

1. apps/api/.env.example — local development defaults
2. .env.self-host.example — self-hosting/production template
3. apps/wiki/content/docs/self-hosting/environment-variables.mdx — user-facing reference

They have drifted repeatedly when new vars land in one location only. Codify
the requirement to update all three in the same change as part of CLAUDE.md so
future Claude-assisted (and human) contributions don't reintroduce the drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:56:23 +02:00
Andy GrunwaldandClaude Opus 4.7 5c166797b5 docs: correct PHISHING_CONFIDENCE_THRESHOLD default in CLAUDE.md
CLAUDE.md listed the default as 85, but the source of truth
(apps/api/src/app/constants.ts:137) and the env vars wiki both use 95. Aligning
CLAUDE.md so future contributors don't propagate the stale value into new code
or docs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:56:15 +02:00
Andy GrunwaldandClaude Opus 4.7 971b98a4cc docs(wiki): document MAIL_FROM_SUBDOMAIN and NGINX_PORT env vars
Both variables exist in .env.self-host.example but were missing from the
user-facing env vars reference, so self-hosters had to read the example file's
inline comments to discover them.

- MAIL_FROM_SUBDOMAIN: added a row to the AWS SES section explaining how the
  prefix combines with a verified domain to construct the MAIL FROM hostname,
  and when to override the default.
- NGINX_PORT: added a new "Advanced" section for variables that almost never
  need tuning, with the host-port override use-case spelled out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:56:00 +02:00
Andy GrunwaldandClaude Opus 4.7 81315eac8e docs(env): add wiki-documented vars to apps/api/.env.example
Brings the dev template into parity with the env vars wiki for variables a
developer might toggle locally:

- PORT (in Environment & Security)
- PLUNK_FROM_ADDRESS (in Plunk API)
- New sections: Notifications (NTFY_URL), Attachments, User Management
  (DISABLE_SIGNUPS, VERIFY_EMAIL_ON_SIGNUP), Phishing Detection

All additions are commented so default local behaviour is unchanged. Vars that
only make sense in the bundled Docker stack (SMTP relay ports, Minio internals,
NEXT_PUBLIC_*, NGINX_PORT) are intentionally omitted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:55:52 +02:00
Andy GrunwaldandClaude Opus 4.7 1c1c95d332 docs(env): sync .env.self-host.example with missing variables
Brings the self-hosting template into parity with apps/api/.env.example and the
env vars wiki. Adds (mostly commented for visibility without changing default
behaviour):

- NODE_ENV, DATABASE_URL, DIRECT_DATABASE_URL, REDIS_URL, PORT
  (Docker auto-configures these via DB_PASSWORD; the lines document overrides)
- Stripe billing block (STRIPE_SK, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_*,
  STRIPE_METER_EVENT_NAME)
- Attachments (MAX_ATTACHMENT_SIZE_MB, MAX_ATTACHMENTS_COUNT)
- SMTP_ENABLED in the SMTP Server block
- VERIFY_EMAIL_ON_SIGNUP in User Management
- Phishing Detection block (OPENROUTER_*, PHISHING_*)

Each was either present in apps/api/.env.example or documented in the wiki but
missing from the production template, forcing self-hosters to read source or
the wiki to discover them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:55:41 +02:00
Andy GrunwaldandClaude Opus 4.7 844be42151 Document boolean and numeric CSV value typing
The preceding commits taught the import worker to coerce custom CSV
column values into JSON booleans and numbers via `coerceCustomValue`
in `apps/api/src/jobs/import-processor.ts`. Without a corresponding
docs update, users can't predict whether a cell like `01234` lands as
a string or as the number `1234`, or which segment-filter operators a
field will expose after import.

Add two bullets to the existing "Rules and limits" list in the
contact-import guide, adjacent to the **Date columns** bullet that
already documents value typing for ISO 8601 dates. The bullets mirror
that style and brevity: one names the boolean keyword set and the
toggle it unlocks in segment filters, the other names the numeric
pattern, the `gt`/`lt` operators it unlocks, and the deliberately
preserved-as-string forms (leading zeros, `+`-prefixed, scientific
notation) so users keep their IDs, zip codes, and phone numbers
intact.

No other content is touched. Closes the documentation gap for
useplunk/plunk#390.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:26:52 +02:00
Andy GrunwaldandClaude Opus 4.7 d59f4a10cd Treat "0" and "1" as numbers, not booleans, in custom CSV columns
The previous coercion treated "1"/"0" as booleans for parity with the
reserved `subscribed` column parser, but in custom columns those values
are far more often counts, quantities, or ids than true/false flags.
Coercing them to booleans hid the numeric segment-filter operators
(`gt`, `lt`) for fields that semantically are numbers, and miscategorised
them in `ContactService.getAvailableFields()` via `jsonb_typeof()`.

Drop "1"/"0" from the truthy/falsy keyword sets in `coerceCustomValue`.
With those entries gone the existing `NUMERIC_RE` branch picks both up
unchanged (the pattern already matches single-digit `0` and `1`), so
they land in `Contact.data` as JSON numbers. Boolean keyword coverage
remains for `true`/`false`/`yes`/`no` (case-insensitive, trimmed).

The reserved `subscribed` column parser at the top of the worker keeps
its own inline keyword set and continues to accept "1"/"0" — that
column is explicitly boolean by contract, so the asymmetry is
intentional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:16:34 +02:00
Andy GrunwaldandClaude Opus 4.7 51ac88b9f5 Coerce custom CSV column values to number during import
Extends the helper introduced for boolean coercion so numeric values
(`42`, `3.14`) become JSON numbers in `Contact.data` instead of strings.
With the right JSON primitive in place, post-import inference via
`jsonb_typeof()` classifies the field as `number`, the dashboard's
segment-filter UI renders a numeric input, and comparison operators
(`gt`, `lt`) become available.

Use a strict integer-or-decimal regex rather than `Number()` to
preserve string-shaped numerics that users intend as identifiers:
leading-zero IDs (`"01234"`), zip codes, phone numbers, and signed or
scientific-notation forms (`"+42"`, `"1e10"`, `".5"`, `"42."`) are left
as strings. Boolean coercion still takes precedence, so `"1"` and `"0"`
remain booleans for consistency with how `subscribed` is parsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:16:34 +02:00
Andy GrunwaldandClaude Opus 4.7 523bcad602 Coerce custom CSV column values to boolean during import
Custom CSV columns with values like "true"/"false" were stored as
strings, which made `ContactService.getAvailableFields()` report them
as string fields and lost the boolean toggle in the dashboard's
segment-filter UI. Only the reserved `subscribed` column was coerced.

Mirror the truthy keyword set already used for `subscribed`
(`true`/`1`/`yes`) and add its symmetric falsy counterpart
(`false`/`0`/`no`) for custom columns. Values outside that keyword set
are returned unchanged so names, IDs, or arbitrary strings are not
corrupted into `false`. Coercion runs over `Object.entries(customData)`
right before the `upsert()` call; no other code path needs to change
because `ContactService.upsert()` already accepts
`Record<string, unknown>` and `mergeContactData()` accepts mixed JSON
primitives. Post-import inference via `jsonb_typeof()` then classifies
the stored JSON boolean as type `boolean` automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:16:33 +02:00
Dries Augustyns 32dd7bba46 feat(tests): enhance test database setup and cleanup for improved isolation and performance 2026-05-22 21:01:03 +02:00
Dries Augustyns 71e2277643 refactor(database): increase Prisma connection pool limits for improved test performance 2026-05-22 20:43:49 +02:00
Dries Augustyns 079e1879b3 test(SecurityService): update test case for complaint count thresholds to reflect new ceiling values 2026-05-22 20:20:45 +02:00
Dries Augustyns 4de40f40fa refactor(SecurityService): update absolute count ceilings for new projects to improve spam detection 2026-05-22 20:12:25 +02:00
Dries Augustyns 94ceadbbe4 feat: add disabledReason field to projects for better tracking of disable reasons 2026-05-22 13:03:32 +02:00
Dries AugustynsandGitHub 9797aed47f Merge pull request #384 from jaschaio/tiptap-aware-html-detection
feat: make detectCustomHtmlPatterns aware of TipTap's actual capabilities
2026-05-17 20:24:49 +02:00
Dries AugustynsandGitHub 8b3657d056 Merge pull request #383 from taniasanz7/patch-28-uniform-filter-row-heights
fix: make email templates, campaigns and workflow search inputs same height as the rest of the app
2026-05-17 19:15:31 +02:00
Dries Augustyns 01ec34a8cb docs: add new recipe pages for waitlist and sync unsubscribes 2026-05-17 18:08:26 +02:00
jaschaio ba3813e242 feat: make detectCustomHtmlPatterns aware of TipTap's actual capabilities
The previous detection tripped on ANY inline `style=` attribute and on
`<span>` elements specifically, which forced templates into HTML-only
editing mode whenever the user had used Visual mode features like text
color. TipTap's TextStyle + Color + Link extensions (configured in
EmailEditor.tsx) natively round-trip exactly that markup -- TipTap emits
`<span style="color: rgb(...)">…</span>` itself when you change a text
color, then the detection rejected it as "custom HTML" on the very next
load. Empirically about 21% of a 156-template corpus tripped this purely
on TipTap-export artifacts (`background-color: initial`, color spans).

This rewrite permits what TipTap can represent and rejects only what it
can't:

* Drop the broad inline-style check entirely (TextStyle/Color/Link
  preserve inline styles on spans and links).
* Remove `<span>` from the custom-elements list (TextStyle handles it).
* Expand the custom-elements list to explicitly cover everything TipTap
  has no extension for: `<div>`, `<section>`, `<article>`, `<header>`,
  `<footer>`, `<nav>`, `<aside>`, `<main>`, full table family
  (`<table>`, `<tr>`, `<td>`, `<th>`, `<tbody>`, `<thead>`, `<tfoot>`,
  `<colgroup>`, `<col>` -- no Table extension is loaded), form/embed/
  media/interactive (`<form>`, `<input>`, `<button>`, `<select>`,
  `<textarea>`, `<iframe>`, `<video>`, `<audio>`, `<svg>`, `<object>`,
  `<embed>`, `<details>`, `<summary>`, `<dialog>`). Single-table is now
  enough to opt out (previously needed nested tables -- harmless
  tightening, single `<table>` already isn't TipTap content).
* Tighten the custom-attributes regex with a leading `[\s"']` boundary so
  query strings like `<a href="…?id=…">` no longer false-match as an
  HTML `id=` attribute.
* `<style>` tags, `@media` queries, and the class allowlist (`prose`,
  `variable-`, `email-image`, `ProseMirror`, `resizable-image`,
  `selected`, `resize-handle`) are unchanged.

Mirrors the same logic in apps/api/src/services/EmailService.ts so the
server-side wrap decision in `EmailService.compile()` stays in lockstep
with the client-side editor-mode decision.

Side-effect on `wrapEmailWithStyles` / `EmailService.compile`: templates
that previously kept their own (unwrapped) shell because they contained
a colored `<span>` or an inline-styled `<a>` will now flow through the
prose wrapper. This is the correct behavior -- those templates ARE
visual-editor output and SHOULD get the same wrapper the preview modal
applies.

Tests: new vitest suite at apps/web/src/lib/__tests__/emailStyles.test.ts
covers 24 cases including the TipTap-export artifacts above, the
href-URL-with-id false-match, and the rejected-element set.
2026-05-17 15:57:37 +02:00
Tania Sanz 283f40239d fix(filters): land templates/workflows/campaigns search inputs at 32px to match filter buttons
The Input atom defaults to h-9 (36px) while the size="sm" filter
Buttons in the same row are h-8 (32px). On /workflows the search bar
is alone in the row so the mismatch isn't visible. On /templates and
/campaigns the type/status filter buttons sit next to the search bar
and the row stretches to the Input's 36px, visibly offsetting the
buttons. Shrink the search Input to h-8 text-xs on all three pages
and add sm:items-center to the filter rows so the controls vertically
center.
2026-05-17 15:57:14 +02:00
Dries Augustyns 53b631e6c6 seo: add data-nosnippet attribute and improve markdown type negotiation 2026-05-17 11:09:54 +02:00
Dries AugustynsandGitHub 6759bffd2a Merge pull request #381 from taniasanz7/patch-29-contact-email-link
feat(contacts): make email cell a link to the contact detail page
2026-05-16 21:45:25 +02:00
Dries AugustynsandGitHub d2496bc51d Merge pull request #368 from ReylanLugo/feat/domains-api-key-auth
feat(api): allow API key authentication for domain endpoints
2026-05-16 21:42:43 +02:00
Dries AugustynsandGitHub bfecf04fa3 Merge pull request #375 from taniasanz7/patch-1-webhook-templating
feat: render template variables in WEBHOOK step url, headers and body
2026-05-16 21:40:55 +02:00
Tania Sanz 6d98d51222 feat(contacts): make email cell a link to the contact detail page
Currently the only entry point from the /contacts list to a contact's
detail page is the small Edit icon-button in the actions column. Make
the email itself a Link to /contacts/:id so the obvious affordance
("click the thing that identifies the row") works too. Applied to both
the desktop table cell and the mobile card variant.
2026-05-16 21:26:18 +02:00
taniasanz7andTania Sanz c484da88ab feat: render template variables in WEBHOOK step url, headers and body 2026-05-16 08:11:35 +02:00
Dries AugustynsandGitHub 1d475c382b Merge pull request #361 from useplunk/release-please--branches--next--components--plunk
chore(next): release 0.11.0
2026-05-13 21:14:25 +02:00
Dries AugustynsandGitHub 3fea06ba2d Merge pull request #374 from taniasanz7/patch-19-dockerignore-nested-build-outputs
fix: .dockerignore excludes nested build outputs (apps/*/dist, .next, .turbo, etc.)
2026-05-13 21:13:56 +02:00
Tania Sanz 1d1d407a6f fix: .dockerignore excludes nested build outputs (apps/*/dist, .next, .turbo, etc.) 2026-05-13 20:59:02 +02:00
github-actions[bot]andGitHub 46b2b8390f chore(next): release 0.11.0 2026-05-13 17:23:20 +00:00
Dries Augustyns 9e4aa9443b fix: improve iframe height adjustment logic in EmailEditor component 2026-05-13 19:22:17 +02:00
Dries Augustyns a27d564e1a fix: implement mergeContactData method for efficient contact data updates 2026-05-13 19:19:11 +02:00
Dries Augustyns 4e95c5a4e4 seo: implement OpenAPI operation rendering and add llms documentation 2026-05-13 17:37:02 +02:00
Dries AugustynsandGitHub 463301b5db Merge pull request #373 from taniasanz7/patch-18-configurable-mail-from-subdomain
feat: make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env
2026-05-12 22:00:21 +02:00
Dries AugustynsandGitHub f178c59b5b Merge pull request #371 from taniasanz7/patch-15-compose-env-passthrough
fix: pass missing env variables from .env.self-host.example to plunk service
2026-05-12 21:50:44 +02:00
Dries Augustyns 6195b39f3d feat: add SwitchOffer component to promote switching from competitors for enhanced user engagement 2026-05-12 21:48:35 +02:00
Tania Sanz e0bf0f628a feat: make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var 2026-05-12 20:35:53 +02:00
Tania Sanz 8a26d605b4 fix: pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example
PR review (#371): scope the self-host baseline to envs that make sense
without reselling Plunk.

docker-compose.yml: pass through only DISABLE_SIGNUPS (private/single-
admin self-host) and EMAIL_RATE_LIMIT_PER_SECOND (works around the
silent 14/sec fallback when ses:GetSendQuota is denied). Drop the
VERIFY_EMAIL_ON_SIGNUP and OPENROUTER_API_KEY passthroughs — signup
hygiene and phishing detection are reselling concerns.

.env.self-host.example: drop the entire Stripe Billing block (billing
only matters when reselling), drop the VERIFY_EMAIL_ON_SIGNUP doc
block for the same reason, and add an EMAIL_RATE_LIMIT_PER_SECOND
section explaining the silent-14/sec fallback so operators know why
they'd set it.
2026-05-12 20:35:53 +02:00
Dries Augustyns 715961c007 fix: handle undefined path in footer component for improved stability 2026-05-12 08:35:12 +02:00
Dries Augustyns ccd516e4e8 feat: add Markdown cut link to footer and page for improved accessibility 2026-05-12 08:07:57 +02:00
ReylanLugo 6aee5db588 refactor(api): rely on auth middleware for domain endpoint permissions
Replace per-route apiKey/jwt branching with auth.projectId from middleware,
matching the contacts controller pattern. Preserve JWT admin gating on
POST/DELETE; API keys are project-scoped by design and skip the role check.
Cross-project domain access by ID now returns 404 instead of 403 to avoid
leaking existence.
2026-05-10 22:44:28 -04:00
Dries Augustyns 649bbf6d6b fix: enhance Quick Start card layout for improved responsiveness and usability 2026-05-10 13:57:46 +02:00
Dries Augustyns e43f70d8a1 fix: update activity item colors and backgrounds for improved visual distinction 2026-05-10 11:59:54 +02:00
Dries Augustyns f22da4add1 feat: implement caching for recent activity count to optimize performance and reduce database load 2026-05-10 11:37:31 +02:00
Dries Augustyns de6335e999 feat: implement bulk contact action selector for improved flexibility in bulk operations 2026-05-10 10:40:40 +02:00
Dries Augustyns 7658a59b5d feat: enhance campaign scheduling and audience settings UI for better clarity and usability 2026-05-10 09:52:25 +02:00
Dries Augustyns aaf5ac6530 feat: refactor template editing layout for improved usability and clarity 2026-05-10 09:24:21 +02:00
ReylanLugo 3f30a48c40 feat(api): allow API key authentication for domain endpoints
Switch /domains controller from `isAuthenticated` (cookie-only) to
`requireAuth` (cookie OR API key), matching the pattern used by other
project-scoped API endpoints (/v1/send, /contacts, etc.).

API keys are project-scoped credentials with full access; for write
operations the projectId in the request must equal the API key's
projectId. JWT (dashboard) auth retains role-based checks
(requireAdminAccess for POST/DELETE).

Also: improve UX when a domain is already linked to the same project
by returning a clear error instead of the generic "linked to another
project" message.

Refactor DomainService.checkDomainOwnership to make `userId` optional
(needed for API key path) while preserving its existing return shape
and adding `projectId` to the result.
2026-05-09 22:25:17 -04:00
Dries Augustyns c6340a1dc7 feat: add workflow duplication functionality with API endpoint and UI button 2026-05-09 16:51:36 +02:00
Dries Augustyns 4ba43dd3b6 feat: add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder 2026-05-09 16:36:47 +02:00
Dries Augustyns d11495061d feat: add search functionality to campaigns list with debounce effect 2026-05-09 09:10:17 +02:00
Dries Augustyns fadc19d139 feat: Ability to change subscription status in workflows 2026-05-09 08:43:05 +02:00
Dries Augustyns 2ea1802290 fix: update TemplateSearchPicker to maintain selected template name on change 2026-05-08 08:03:15 +02:00
Dries Augustyns 15bdcf6e49 seo: Enhance copy of landing page 2026-05-07 09:21:30 +02:00
Dries Augustyns 2529c9b428 fix: enhance campaign finalization process to handle pending emails and ensure accurate status updates 2026-05-06 21:48:54 +02:00
Dries Augustyns 4ddafdc041 docs: expand documentation with new sections on importing contacts, unsubscribe pages, and API key management 2026-05-06 21:37:00 +02:00
Dries Augustyns 88be252a29 seo: add comparison pages for Mailjet, Buttondown, and Amazon SES 2026-05-06 20:21:49 +02:00
Dries Augustyns db5dfc46ce seo: add MX record checker and header checker tools 2026-05-06 19:40:25 +02:00
Dries Augustyns e2ce5d4e96 seo: Add dkim, dmarc, spf check 2026-05-06 17:06:14 +02:00
Dries Augustyns 735acff454 feat: add sanitize-html for improved email content sanitization 2026-05-06 11:06:50 +02:00
Dries Augustyns 1193d6c7e6 log: remove detailed logging of full SES notification for inbound email 2026-05-06 10:46:47 +02:00
Dries Augustyns 62661ae45f log: improve logging for inbound email notifications and enhance email content parsing 2026-05-06 10:37:39 +02:00
Dries Augustyns 4587e9d670 fix: enhance email content parsing and logging for better debugging 2026-05-05 20:35:36 +02:00
Dries Augustyns 88a8a02e53 ui: add SVG favicon and update favicon links for consistency 2026-05-05 08:34:41 +02:00
Dries Augustyns cbb3bffcf4 fix: update middleware matcher to exclude webmanifest files 2026-05-05 08:25:51 +02:00
Dries Augustyns 80ef65e04b docs: update README to clarify self-hosted alternative and add inbound emails feature 2026-05-04 21:15:54 +02:00
Dries Augustyns 361ec0b1eb refactor: convert forwardRef components to function components for consistency 2026-05-04 21:08:14 +02:00
Dries Augustyns ed9027b4ef refactor: implement step dialog components for workflow editing 2026-05-04 20:25:19 +02:00
Dries Augustyns d1f357c300 fix: update segment filter logic to retain value and unit, enhance activity name mapping 2026-05-04 19:39:32 +02:00
Dries Augustyns a126563542 fix: update segment filter logic to retain value and unit, enhance activity name mapping 2026-05-04 19:35:29 +02:00
Dries Augustyns a3cc62213f feat: add segment membership operators and enhance segment filter functionality 2026-05-04 19:07:04 +02:00
Dries Augustyns 2d05c3fbc1 refactor: remove unused .png files 2026-05-04 18:17:31 +02:00
Dries Augustyns c0752e1363 ui: refine README introduction for clarity and conciseness 2026-05-04 18:16:30 +02:00
Dries Augustyns 1b09ec1e13 ui: enhance Open Graph image dimensions and styling for improved social sharing 2026-05-04 18:14:55 +02:00
Dries Augustyns 0f00ca1b8c feat: add 'notTriggeredWithin' operator to segment filters for enhanced event tracking 2026-05-04 18:04:24 +02:00
Dries Augustyns 7615b62945 fix: update not found handling in GET route to return 404 response 2026-05-04 14:09:19 +02:00
Dries Augustyns ba23a1c6fa fix: replace font loading method with utility function for improved performance 2026-05-04 12:45:15 +02:00
Dries Augustyns 4b5815c1d6 ui: update Open Graph and Twitter card images for various pages to enhance social sharing 2026-05-04 12:32:51 +02:00
Dries Augustyns 29883ffc71 fix: enhance project name validation to exclude invisible and decorative characters 2026-05-03 08:14:18 +02:00
Dries Augustyns 5724ab9536 feat: add project switching functionality to command palette 2026-05-02 16:28:25 +02:00
Dries Augustyns 0c15cc84df seo: update descriptions for Brevo, Customer.io, and other email alternatives to enhance clarity and appeal 2026-05-02 15:02:23 +02:00
Dries Augustyns a729789922 seo: add spam checker tool with analysis for email content and subject lines 2026-05-02 14:03:58 +02:00
Dries Augustyns 26ec5864ae Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-05-01 21:55:45 +02:00
Dries Augustyns 48425d1df1 feat: implement early fraud warning handling in webhooks 2026-05-01 21:55:32 +02:00
Dries AugustynsandGitHub a5e8d239ee Merge pull request #356 from useplunk/release-please--branches--next--components--plunk 2026-05-01 20:13:21 +02:00
Dries Augustyns 532293e52a Resolve linting errors 2026-05-01 19:55:58 +02:00
github-actions[bot]andGitHub fd066a6991 chore(next): release 0.10.0 2026-05-01 17:45:19 +00:00
Dries Augustyns bb72911fa2 feat: enhance contact addition with bulk email lookup and subscription options 2026-05-01 19:44:48 +02:00
Dries Augustyns 9fafeb7d4b ui: adjust spacing for customer logos section 2026-05-01 10:57:37 +02:00
Dries Augustyns 11ea9d5e12 ui: define type for customers array 2026-05-01 10:43:54 +02:00
Dries Augustyns b73927067f ui: add customer logos for SnowSEO and Viral 2026-05-01 10:39:42 +02:00
Dries Augustyns 4b65e718ad ui: add customer logos for Dodo Payments, Krumzi, and Waidwissen 2026-04-30 17:50:25 +02:00
Dries AugustynsandGitHub f6f291966f Merge pull request #359 from mre/fix/list-unsubscribe-header 2026-04-30 15:31:50 +02:00
Matthias Endler 11c428d4ff fix(ses): emit List-Unsubscribe inside the header section, not the body
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.

Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.

Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
2026-04-30 15:20:06 +02:00
Dries Augustyns 2eb407993b feat: add configurable attachment limits for email service
Closes #358
2026-04-29 08:48:03 +02:00
Dries Augustyns 10946511b0 feat: enhance contact data handling by filtering empty strings and allowing null to delete fields 2026-04-27 16:59:18 +02:00
Dries Augustyns 5f741c66c7 fix: update project icon colors for improved visibility and consistency 2026-04-24 19:23:33 +02:00
Dries Augustyns d7eb85ffdd fix: update project disabled messages for clarity and consistency 2026-04-24 19:21:07 +02:00
Dries Augustyns 1c89ed083e feat: implement CommandPalette for enhanced navigation and recent pages tracking 2026-04-24 18:42:04 +02:00
Dries Augustyns f40cc86cec fix: Harmonize rings and hover states 2026-04-24 16:01:18 +02:00
Dries Augustyns 6f31fa2503 feat: add updateActiveProject function for in-place project updates 2026-04-24 14:58:06 +02:00
Dries Augustyns 98f1e80fca fix: prevent unnecessary state updates in search input effect 2026-04-24 13:49:16 +02:00
Dries Augustyns c1bcd358cc fix: Consistency across buttons and labels 2026-04-24 13:35:25 +02:00
Dries Augustyns 517753c420 fix: Consistency across cards 2026-04-24 12:54:43 +02:00
Dries Augustyns ab3edd7dc9 fix: improve layout and accessibility of workflow header and buttons 2026-04-24 12:09:54 +02:00
Dries Augustyns c910d20bb7 feat: Enhance ease of use of workflow editor 2026-04-24 11:56:43 +02:00
Dries Augustyns bdc9b30ef2 fix: standardize step type labels and update visual styles in workflow components 2026-04-24 10:17:10 +02:00
Dries Augustyns c8190b953f fix: Content negotiation for xml and txt 2026-04-23 08:04:39 +02:00
Dries Augustyns 1637d54e1b fix: add project name and sender email parameters to phishing content check 2026-04-22 22:03:57 +02:00
Dries Augustyns 15294a40d8 fix: add project name and sender email parameters to phishing content check 2026-04-22 22:03:53 +02:00
Dries Augustyns bb78c86c40 fix: update phishing confidence threshold to 95% 2026-04-22 21:59:34 +02:00
Dries Augustyns c0e0ad8bc6 fix: add new configuration options for phishing detection thresholds 2026-04-22 13:59:41 +02:00
Dries Augustyns 208c809a90 fix: Add in-memory cache for lower-confidence phishing checks 2026-04-22 07:40:23 +02:00
Dries Augustyns b7eb2549d4 fix: turn warning into success log 2026-04-21 22:08:07 +02:00
Dries Augustyns 91d0d2d297 fix: update response format for phishing analysis and improve JSON parsing comment 2026-04-21 21:53:16 +02:00
Dries Augustyns 862875428d fix: log message for projects passing phishing checks 2026-04-21 21:49:57 +02:00
Dries Augustyns 2da8b065d0 feat: implement phishing detection using OpenRouter API with configurable sampling rate 2026-04-21 21:28:31 +02:00
Dries Augustyns 60146fcc10 ui: replace custom loading spinners with IconSpinner component across multiple files 2026-04-21 16:28:03 +02:00
Dries Augustyns c278a0fd94 feat: add className prop to EmptyState component for custom styling 2026-04-21 10:31:17 +02:00
Dries Augustyns 0b3aa28101 feat: add middleware support for .md file rewrites 2026-04-21 10:29:15 +02:00
Dries Augustyns ecaf99665a ui: Update empty states 2026-04-21 10:24:35 +02:00
Dries Augustyns 3d9b4e4eb3 ui: Update empty states 2026-04-21 10:22:05 +02:00
Dries Augustyns e048fc1570 feat: implement meter event processing with queue for Stripe billing 2026-04-20 21:30:52 +02:00
Dries Augustyns 4322de929a fix: reconcile totalRecipients in CampaignService to prevent stuck campaigns
closes #348
2026-04-20 20:15:56 +02:00
Dries AugustynsandGitHub bfdfde68fb Merge pull request #352 from wcatbb/next 2026-04-20 19:48:04 +02:00
Dries Augustyns 3649392b47 fix: Sync display name in TemplateSearchPicker when initialName changes 2026-04-20 19:47:39 +02:00
Dries AugustynsandGitHub e450ae62a3 Merge pull request #338 from useplunk/release-please--branches--next--components--plunk 2026-04-20 19:29:33 +02:00
github-actions[bot]andGitHub d56e876428 chore(next): release 0.9.0 2026-04-20 16:40:54 +00:00
Dries Augustyns b79d416667 feat: implement AWS SNS signature verification in SecurityService 2026-04-20 18:40:19 +02:00
Dries Augustyns bf12392b88 feat: integrate DOMPurify for sanitizing HTML content 2026-04-20 18:33:23 +02:00
Dries AugustynsandGitHub ae9d5a6f3d Merge pull request #343 from useplunk/dependabot/npm_and_yarn/vite-7.3.2 2026-04-20 18:26:25 +02:00
Dries AugustynsandGitHub 1362e4672f Merge pull request #349 from useplunk/dependabot/npm_and_yarn/lodash-4.18.1 2026-04-20 18:26:15 +02:00
Dries AugustynsandGitHub 35a830fa7b Merge pull request #350 from useplunk/dependabot/npm_and_yarn/next-16.2.3 2026-04-20 18:26:00 +02:00
Dries AugustynsandGitHub 742821eba9 Merge pull request #318 from useplunk/dependabot/npm_and_yarn/undici-6.24.1 2026-04-20 18:25:48 +02:00
Dries AugustynsandGitHub 91f80de981 Merge pull request #340 from useplunk/dependabot/npm_and_yarn/defu-6.1.6 2026-04-20 18:25:38 +02:00
Dries AugustynsandGitHub c584e42c54 Merge pull request #326 from useplunk/dependabot/npm_and_yarn/socket.io-parser-4.2.6 2026-04-20 18:25:27 +02:00
Dries Augustyns 545d733794 docs: Add content negotiation to apps/wiki 2026-04-20 17:51:02 +02:00
Dries Augustyns 37ed1e49b7 fix: Implement content negotiation for markdown and html in middleware 2026-04-20 17:20:34 +02:00
Dries Augustyns df76be1b87 feat: Add accept/markdown to apps/landing 2026-04-20 17:09:56 +02:00
Dries Augustyns bd5a085802 feat: Add onboarding flow 2026-04-19 11:26:12 +02:00
Dries Augustyns bc4c79235e Resolve linting error 2026-04-19 10:03:14 +02:00
Dries Augustyns f2ebce0ef2 feat: Enhance login forms with last used authentication method 2026-04-19 09:53:28 +02:00
Dries AugustynsandGitHub e10a94b2bc Merge pull request #355 from useplunk/dev-driaug-marketing-polish 2026-04-19 09:25:46 +02:00
Dries Augustyns 52fc7f73ce ui: Use Link instead of a 2026-04-19 09:18:50 +02:00
Dries Augustyns 428079e632 ui: Transparent logo backgrounds 2026-04-19 09:09:46 +02:00
Dries Augustyns 19ea8b0122 ui: Polish details 2026-04-19 09:03:50 +02:00
Dries Augustyns 6fdc29fc0f ui: Fix double period in SEO description 2026-04-18 20:19:17 +02:00
Dries Augustyns c91d8c2e29 ui: Polish marketing website 2026-04-18 13:39:22 +02:00
Dries AugustynsandGitHub 3bb3411994 Merge pull request #354 from useplunk/dev-driaug-ui-polish 2026-04-17 12:42:30 +02:00
Dries Augustyns 28fe7093f4 ui: Enhance contact details page with improved layout and loading indicators 2026-04-17 12:31:51 +02:00
Dries Augustyns 0803fd7bc4 ui: Clean up empty states, modals, animations 2026-04-17 12:27:40 +02:00
Dries Augustyns 03a058ad48 ui: Update user management pages 2026-04-17 11:03:12 +02:00
Dries Augustyns 5e62f517fc feat: Update new project bounce thresholds for stricter email handling 2026-04-16 08:05:56 +02:00
Dries Augustyns 44c86572c2 feat: Block domain changes for disabled projects with appropriate error handling 2026-04-16 07:56:41 +02:00
Dries Augustyns effdfdb6d6 test: Update absolute count ceilings tests for established projects with project aging logic 2026-04-15 21:04:53 +02:00
Dries Augustyns c0b2abaeca feat: Enhance security metrics handling with new thresholds and improved messaging 2026-04-15 20:55:42 +02:00
Dries Augustyns e3395083db feat: Enhance email processing to include parsed HTML body content in inbound email records
Closes #342
2026-04-13 17:58:52 +02:00
Dries Augustyns ae64c2dbb5 feat: Add emailId field to webhook events for better correlation with send requests
Closes #344
2026-04-13 17:15:41 +02:00
Dries Augustyns 52cb2b6c77 fix: Refactor date filtering logic for pagination in ActivityFeed and ActivityService 2026-04-13 17:06:22 +02:00
dependabot[bot]andGitHub a6d5740b77 chore(deps): bump next from 16.1.7 to 16.2.3
Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.3)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 18:59:26 +00:00
dependabot[bot]andGitHub 6114c21a3f chore(deps): bump lodash from 4.17.23 to 4.18.1
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 03:58:34 +00:00
dependabot[bot]andGitHub 4ce4c8c2a4 chore(deps): bump vite from 7.2.7 to 7.3.2
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.7 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-06 21:35:49 +00:00
dependabot[bot]andGitHub c3967a98b0 chore(deps): bump defu from 6.1.4 to 6.1.6
Bumps [defu](https://github.com/unjs/defu) from 6.1.4 to 6.1.6.
- [Release notes](https://github.com/unjs/defu/releases)
- [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.6)

---
updated-dependencies:
- dependency-name: defu
  dependency-version: 6.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-04 06:52:12 +00:00
dependabot[bot]andGitHub 474c822640 chore(deps): bump socket.io-parser from 4.2.4 to 4.2.6
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.4 to 4.2.6.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.4...socket.io-parser@4.2.6)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 01:05:14 +00:00
dependabot[bot]andGitHub c83541c3eb chore(deps): bump undici from 6.23.0 to 6.24.1
Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-14 09:11:29 +00:00
310 changed files with 27344 additions and 16473 deletions
+13 -4
View File
@@ -1,13 +1,22 @@
# Dependencies
node_modules
**/node_modules
.pnp
.pnp.js
# Build outputs
dist
.next
.turbo
out
# Patterns without a leading `**/` only match the build-context root, NOT
# nested directories. In this monorepo the actual outputs live under
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/`
# any stale local builds (from running `yarn dev` or `yarn build` on the
# host) get copied into the image and override the freshly-built outputs,
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
# bundle still references).
**/dist
**/.next
**/.turbo
**/build
**/out
# Development
.env
+30 -14
View File
@@ -52,6 +52,13 @@ SES_CONFIGURATION_SET=plunk-configuration-set
# When set, projects can choose to disable email tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
# is already used for something else (e.g. an R2/CDN custom domain), since the
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
# MAIL_FROM_SUBDOMAIN=
# ========================================
# OPTIONAL: OAuth Login
# ========================================
@@ -60,15 +67,6 @@ GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
# ========================================
# OPTIONAL: Stripe Billing
# ========================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING=
STRIPE_PRICE_EMAIL_USAGE=
STRIPE_METER_EVENT_NAME=emails
# ========================================
# OPTIONAL: File Storage (Minio)
# ========================================
@@ -154,11 +152,29 @@ SMTP_DOMAIN=smtp.example.com
# Default: false
# DISABLE_SIGNUPS=false
# Controls whether email validation checks are performed on signup
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
# When disabled (false), skips these validation checks and allows any email format
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ========================================
# OPTIONAL: SES Sending Rate
# ========================================
# Caps the email-worker's send rate (messages per second) for the SES sandbox or
# manually-throttled accounts. When unset, the worker probes the AWS account at
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
# the worker silently falls back to 14/sec which may exceed sandbox limits and
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
# Default: unset (auto-detect, falls back to 14)
# EMAIL_RATE_LIMIT_PER_SECOND=1
# Number of emails the worker processes in parallel. When unset, concurrency is
# derived from the effective rate limit (~ rate * 0.5, min 5, capped by
# EMAIL_WORKER_MAX_CONCURRENCY) so a higher SES quota translates into higher
# throughput automatically. Pin this only when the Prisma pool or memory is the
# binding constraint.
# Default: unset (auto-derived)
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound applied to the auto-derived concurrency. Raise this when your SES
# quota is high AND the Prisma connection pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# ========================================
# ADVANCED (rarely needed)
+12
View File
@@ -65,6 +65,18 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Tune Postgres for ephemeral CI workload
env:
PGPASSWORD: postgres
run: |
# synchronous_commit=off is the biggest single I/O win and is safe to lose
# data on crash for a throwaway CI database.
# synchronous_commit is dynamic — applies on reload. max_connections would
# require a restart, so we leave it at the default of 100 and cap workers
# at 4 × connection_limit=20 = 80 to stay under that budget.
psql -h localhost -U postgres -d plunk_test -c "ALTER SYSTEM SET synchronous_commit = 'off';"
psql -h localhost -U postgres -d plunk_test -c "SELECT pg_reload_conf();"
- name: Setup environment variables
run: |
cat > .env << EOF
+17
View File
@@ -0,0 +1,17 @@
## Design Context
### Users
Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first.
### Brand Personality
Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*.
### Aesthetic Direction
Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous.
### Design Principles
1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it.
2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration.
3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions.
4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous.
5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI.
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.8.0"
".": "0.11.0"
}
+131
View File
@@ -1,5 +1,136 @@
# Changelog
## [0.11.0](https://github.com/useplunk/plunk/compare/v0.10.0...v0.11.0) (2026-05-13)
### Features
* Ability to change subscription status in workflows ([fadc19d](https://github.com/useplunk/plunk/commit/fadc19d139084550eb442a8b8368edcd6075cad0))
* add 'notTriggeredWithin' operator to segment filters for enhanced event tracking ([0f00ca1](https://github.com/useplunk/plunk/commit/0f00ca1b8c9bd95f2158af845218ef432ab0d498))
* add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder ([4ba43dd](https://github.com/useplunk/plunk/commit/4ba43dd3b64776413f813e091f527aa3e56bfc84))
* add Markdown cut link to footer and page for improved accessibility ([ccd516e](https://github.com/useplunk/plunk/commit/ccd516e4e882921fb1a8a3e87c47710e51e35311))
* add project switching functionality to command palette ([5724ab9](https://github.com/useplunk/plunk/commit/5724ab9536ea4e3bb4e916e963b1f39ba51bb7f3))
* add sanitize-html for improved email content sanitization ([735acff](https://github.com/useplunk/plunk/commit/735acff45423cb537f7aa636b9a9d31830775f93))
* add search functionality to campaigns list with debounce effect ([d114950](https://github.com/useplunk/plunk/commit/d11495061d907bcee699d56987784fa7ebe31bf6))
* add segment membership operators and enhance segment filter functionality ([a3cc622](https://github.com/useplunk/plunk/commit/a3cc62213f40f6a9341113b73b52852292fb9a10))
* add SwitchOffer component to promote switching from competitors for enhanced user engagement ([6195b39](https://github.com/useplunk/plunk/commit/6195b39f3dbdd9fe783b79f9ae7f7942509a49a0))
* add workflow duplication functionality with API endpoint and UI button ([c6340a1](https://github.com/useplunk/plunk/commit/c6340a1dc7385723d4ec30779340218935d814e6))
* enhance campaign scheduling and audience settings UI for better clarity and usability ([7658a59](https://github.com/useplunk/plunk/commit/7658a59b5df4e325ad9eca37356cad3cbb70e942))
* implement bulk contact action selector for improved flexibility in bulk operations ([de6335e](https://github.com/useplunk/plunk/commit/de6335e99999242f81e9eda9a20aeccab80a2de4))
* implement caching for recent activity count to optimize performance and reduce database load ([f22da4a](https://github.com/useplunk/plunk/commit/f22da4add1e455678f380aba8c2fd02012ef6457))
* implement early fraud warning handling in webhooks ([48425d1](https://github.com/useplunk/plunk/commit/48425d1df160a3fd5edb4cbdcc9e6725628db319))
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env ([463301b](https://github.com/useplunk/plunk/commit/463301b5db27b08b483db3c56a992ac62d6653b8))
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var ([e0bf0f6](https://github.com/useplunk/plunk/commit/e0bf0f628af2a155ca3bbc38ef5c2f4ecc0455e6))
* refactor template editing layout for improved usability and clarity ([aaf5ac6](https://github.com/useplunk/plunk/commit/aaf5ac65307266a4d9deb02986b610757e929b49))
### Bug Fixes
* enhance campaign finalization process to handle pending emails and ensure accurate status updates ([2529c9b](https://github.com/useplunk/plunk/commit/2529c9b4282b003a94013259119e98654ae22b46))
* enhance email content parsing and logging for better debugging ([4587e9d](https://github.com/useplunk/plunk/commit/4587e9d670fa3a27fbc1ca7669c8fc75d804e85e))
* enhance project name validation to exclude invisible and decorative characters ([29883ff](https://github.com/useplunk/plunk/commit/29883ffc7104d4beb13965bfa65ea609367ede69))
* enhance Quick Start card layout for improved responsiveness and usability ([649bbf6](https://github.com/useplunk/plunk/commit/649bbf6d6b9db5139295a455c69425a5565d4323))
* handle undefined path in footer component for improved stability ([715961c](https://github.com/useplunk/plunk/commit/715961c007e450731434e5f343acff0debd6a851))
* implement mergeContactData method for efficient contact data updates ([a27d564](https://github.com/useplunk/plunk/commit/a27d564e1af6e5cf72c9ef405650f2475bfd0c86))
* improve iframe height adjustment logic in EmailEditor component ([9e4aa94](https://github.com/useplunk/plunk/commit/9e4aa9443b53ef38a3af1b269f4a949dd57e758f))
* pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example ([8a26d60](https://github.com/useplunk/plunk/commit/8a26d605b4aa5fdea0e02a17159c1cae478a7e47))
* pass missing env variables from .env.self-host.example to plunk service ([f178c59](https://github.com/useplunk/plunk/commit/f178c59b5bc461f9ba57dc6f0bac9617feaab4a3))
* replace font loading method with utility function for improved performance ([ba23a1c](https://github.com/useplunk/plunk/commit/ba23a1c6fa70ca6fa0343391fa8d642155ad45c2))
* update activity item colors and backgrounds for improved visual distinction ([e43f70d](https://github.com/useplunk/plunk/commit/e43f70d8a135b048d4e9ed013ffa25f1b2f89687))
* update middleware matcher to exclude webmanifest files ([cbb3bff](https://github.com/useplunk/plunk/commit/cbb3bffcf4f789e9c36912c9b0ce0ba9f6a1d4dd))
* update not found handling in GET route to return 404 response ([7615b62](https://github.com/useplunk/plunk/commit/7615b62945aa1939bf6a5b3638c232509090de60))
* update segment filter logic to retain value and unit, enhance activity name mapping ([d1f357c](https://github.com/useplunk/plunk/commit/d1f357c300c7798d5c8d521e1219e39e7da821dd))
* update segment filter logic to retain value and unit, enhance activity name mapping ([a126563](https://github.com/useplunk/plunk/commit/a1265635420e66b7cdef02a7136fdf9c3aec4418))
* update TemplateSearchPicker to maintain selected template name on change ([2ea1802](https://github.com/useplunk/plunk/commit/2ea1802290a4378d68e3878a44e502ea8541b301))
### Code Refactoring
* convert forwardRef components to function components for consistency ([361ec0b](https://github.com/useplunk/plunk/commit/361ec0b1eb17647696656c6a6d4cddd5b9348b45))
* implement step dialog components for workflow editing ([ed9027b](https://github.com/useplunk/plunk/commit/ed9027b4ef9c37e91c69ed5a90d5b74c08c7a054))
* remove unused .png files ([2d05c3f](https://github.com/useplunk/plunk/commit/2d05c3fbc1ded87880a307943bb93106b18cd406))
### Documentation
* expand documentation with new sections on importing contacts, unsubscribe pages, and API key management ([4ddafdc](https://github.com/useplunk/plunk/commit/4ddafdc0419389818de7e45eb4a0c82c2e9382eb))
* update README to clarify self-hosted alternative and add inbound emails feature ([80ef65e](https://github.com/useplunk/plunk/commit/80ef65e04b8c362f05ae5b7d5710b398a150cce5))
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
### Features
* add className prop to EmptyState component for custom styling ([c278a0f](https://github.com/useplunk/plunk/commit/c278a0fd944977d4ea3a0c5a4a43ff4cc0a13aa3))
* add configurable attachment limits for email service ([2eb4079](https://github.com/useplunk/plunk/commit/2eb407993bd5fed3e74be73445185e517c18b994)), closes [#358](https://github.com/useplunk/plunk/issues/358)
* add middleware support for .md file rewrites ([0b3aa28](https://github.com/useplunk/plunk/commit/0b3aa28101d50a734945ce19b924f9aa30631c64))
* add updateActiveProject function for in-place project updates ([6f31fa2](https://github.com/useplunk/plunk/commit/6f31fa2503b11e3a9a37d753086bbb74b42f7bc4))
* enhance contact addition with bulk email lookup and subscription options ([bb72911](https://github.com/useplunk/plunk/commit/bb72911fa25e3a204095e74c07efbc261154ac30))
* enhance contact data handling by filtering empty strings and allowing null to delete fields ([1094651](https://github.com/useplunk/plunk/commit/10946511b019ff76ce5f785a39cc0db4ea9ce820))
* Enhance ease of use of workflow editor ([c910d20](https://github.com/useplunk/plunk/commit/c910d20bb71ff5de4abf95f3f96be9c435342cb4))
* implement CommandPalette for enhanced navigation and recent pages tracking ([1c89ed0](https://github.com/useplunk/plunk/commit/1c89ed083e49148967713e9a9c2f7f8833db07c4))
* implement meter event processing with queue for Stripe billing ([e048fc1](https://github.com/useplunk/plunk/commit/e048fc157080b36f40fbfc4440db51367a2d374b))
* implement phishing detection using OpenRouter API with configurable sampling rate ([2da8b06](https://github.com/useplunk/plunk/commit/2da8b065d08438aa2adea397d8ab2a560e996af6))
### Bug Fixes
* Add in-memory cache for lower-confidence phishing checks ([208c809](https://github.com/useplunk/plunk/commit/208c809a90d0721261e643523b338d05e23e2bf9))
* add new configuration options for phishing detection thresholds ([c0e0ad8](https://github.com/useplunk/plunk/commit/c0e0ad8bc6bfdddea5193191156d18aad440c2b0))
* add project name and sender email parameters to phishing content check ([1637d54](https://github.com/useplunk/plunk/commit/1637d54e1b132dd6864fb1c50d23a388c1c47bf5))
* add project name and sender email parameters to phishing content check ([15294a4](https://github.com/useplunk/plunk/commit/15294a40d88289fa8fd876c9e7c201eb1e2ca956))
* Consistency across buttons and labels ([c1bcd35](https://github.com/useplunk/plunk/commit/c1bcd358cc611594d851414687c04bf7a03759a1))
* Consistency across cards ([517753c](https://github.com/useplunk/plunk/commit/517753c420204510e06decec01db7749894a9265))
* Content negotiation for xml and txt ([c8190b9](https://github.com/useplunk/plunk/commit/c8190b953f9f2e28e6cfa98e72d8dcfbba7e251b))
* Harmonize rings and hover states ([f40cc86](https://github.com/useplunk/plunk/commit/f40cc86cec661899fb862b56ce3e35bf70b33e54))
* improve layout and accessibility of workflow header and buttons ([ab3edd7](https://github.com/useplunk/plunk/commit/ab3edd7dc9030eeb770ffd64087476adf10d2d06))
* log message for projects passing phishing checks ([8628754](https://github.com/useplunk/plunk/commit/862875428d21de03643e8e51126cc59abef95ccb))
* prevent unnecessary state updates in search input effect ([98f1e80](https://github.com/useplunk/plunk/commit/98f1e80fca8253c125ba24c4d84c5be86841e1fa))
* reconcile totalRecipients in CampaignService to prevent stuck campaigns ([4322de9](https://github.com/useplunk/plunk/commit/4322de929a84279370bedcbb4f6b7dcd81145aea)), closes [#348](https://github.com/useplunk/plunk/issues/348)
* **ses:** emit List-Unsubscribe inside the header section, not the body ([11c428d](https://github.com/useplunk/plunk/commit/11c428d4ffe7c8aa87aac4d24148aaa95e01507a))
* standardize step type labels and update visual styles in workflow components ([bdc9b30](https://github.com/useplunk/plunk/commit/bdc9b30ef29396870aa42515b8e688485a4673e8))
* Sync display name in TemplateSearchPicker when initialName changes ([3649392](https://github.com/useplunk/plunk/commit/3649392b475271198cd61295091e8acf708efa25))
* Sync display name in TemplateSearchPicker when initialName changes ([cbde3cc](https://github.com/useplunk/plunk/commit/cbde3cce3fcee382dae04e79a9480e67ad896725))
* turn warning into success log ([b7eb254](https://github.com/useplunk/plunk/commit/b7eb2549d433552f32783b6fb8c192f5afb48ecd))
* update phishing confidence threshold to 95% ([bb78c86](https://github.com/useplunk/plunk/commit/bb78c86c400b60fb0e84c7872e20ad6f7c43c199))
* update project disabled messages for clarity and consistency ([d7eb85f](https://github.com/useplunk/plunk/commit/d7eb85ffdd99177c5e27104779281f5a08fe36a7))
* update project icon colors for improved visibility and consistency ([5f741c6](https://github.com/useplunk/plunk/commit/5f741c66c7f0c9c25c45b26917ff68529d15f958))
* update response format for phishing analysis and improve JSON parsing comment ([91d0d2d](https://github.com/useplunk/plunk/commit/91d0d2d297cb38fe0dfdfd7f73df355e2f51096a))
## [0.9.0](https://github.com/useplunk/plunk/compare/v0.8.0...v0.9.0) (2026-04-20)
### Features
* Add accept/markdown to apps/landing ([df76be1](https://github.com/useplunk/plunk/commit/df76be1b87de14769a219ae0e6c99711edf993a1))
* Add emailId field to webhook events for better correlation with send requests ([ae64c2d](https://github.com/useplunk/plunk/commit/ae64c2dbb5b76f16b562641953f262d76ea0d8e8)), closes [#344](https://github.com/useplunk/plunk/issues/344)
* Add headless template type ([fb5aa87](https://github.com/useplunk/plunk/commit/fb5aa8796a8550be7a70fdd91616c9a41ed3047d))
* Add onboarding flow ([bd5a085](https://github.com/useplunk/plunk/commit/bd5a0858021b9d74fae07cca5d2a0b8c10deabd7))
* Add type to campaign ([d24259e](https://github.com/useplunk/plunk/commit/d24259e8d202edd1b8c7771a63cc212c982b52e1))
* Block domain changes for disabled projects with appropriate error handling ([44c8657](https://github.com/useplunk/plunk/commit/44c86572c275221b5068605c64958f43a1f96ff4))
* Disable projects on failed payment ([3343e89](https://github.com/useplunk/plunk/commit/3343e891bdbcfee7d17d73a0d7fb181652e7c262))
* Enhance email processing to include parsed HTML body content in inbound email records ([e339508](https://github.com/useplunk/plunk/commit/e3395083db11fdb4b6cfdbace46518792ec132a3)), closes [#342](https://github.com/useplunk/plunk/issues/342)
* Enhance login forms with last used authentication method ([f2ebce0](https://github.com/useplunk/plunk/commit/f2ebce0ef2ac9c9c4e994cb754f67f6fa8a3f465))
* Enhance security metrics handling with new thresholds and improved messaging ([c0b2aba](https://github.com/useplunk/plunk/commit/c0b2abaeca5656743e189f975ab82ff9b25b577b))
* implement AWS SNS signature verification in SecurityService ([b79d416](https://github.com/useplunk/plunk/commit/b79d4166671cc04cc1458d2c24af262af0e16c9e))
* integrate DOMPurify for sanitizing HTML content ([bf12392](https://github.com/useplunk/plunk/commit/bf12392b88fc92b0760333e1b568e3d6d1b6ed74))
* Update new project bounce thresholds for stricter email handling ([5e62f51](https://github.com/useplunk/plunk/commit/5e62f517fc896849c270e795cd31a110b83707cc))
### Bug Fixes
* Enhance email processing to support campaign types and improve unsubscribe logic ([9e2400c](https://github.com/useplunk/plunk/commit/9e2400c6de6f13c498f02c489f956ebf89de36b0))
* Hint custom event names in combobox when no matches are found ([3214f6c](https://github.com/useplunk/plunk/commit/3214f6c42d4ca6ab628e905ac69ed82648ccf47c))
* Implement content negotiation for markdown and html in middleware ([37ed1e4](https://github.com/useplunk/plunk/commit/37ed1e49b78eb9339e7b62fd2fd5b60d451707d6))
* Implement SSRF protection in webhook handling with safeFetch method ([2c5a715](https://github.com/useplunk/plunk/commit/2c5a71518da358927bd5e41035b85fd278790b47))
* Refactor date filtering logic for pagination in ActivityFeed and ActivityService ([52cb2b6](https://github.com/useplunk/plunk/commit/52cb2b6c77bc3912b356887bdd48e9c8b9b1728a))
* Update language validation regex to support locale variants ([7834b9e](https://github.com/useplunk/plunk/commit/7834b9e7ef615ba029a1a091b0c37c8f40eb543c))
### Documentation
* Add content negotiation to apps/wiki ([545d733](https://github.com/useplunk/plunk/commit/545d7337948f41f006f9cb0270b4bfff2b08ed2c))
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
+25
View File
@@ -154,6 +154,16 @@ Required for builds and deployment (see turbo.json and .env.example):
plus-addressing, domain existence, and MX records
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
bounce/complaint rate thresholds are exceeded
- Attachment Limits (optional):
- `MAX_ATTACHMENT_SIZE_MB` (default: 10) - Maximum total attachment size in megabytes per email. AWS SES supports up to 40 MB.
- `MAX_ATTACHMENTS_COUNT` (default: 10) - Maximum number of attachments per email
- Phishing Detection (optional):
- `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection)
- `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis
- `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%)
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 95) - Minimum confidence percentage (0-100) to auto-disable project for single detection
- `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable
- `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour)
**Important Notes:**
@@ -164,6 +174,21 @@ Required for builds and deployment (see turbo.json and .env.example):
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access
## Environment Variable Changes
When you add, rename, remove, or change the default/behaviour of any environment variable, you MUST update all THREE of the following in the same change:
1. `apps/api/.env.example` — local development defaults
2. `.env.self-host.example` — self-hosting / production template
3. `apps/wiki/content/docs/self-hosting/environment-variables.mdx` — user-facing reference
Rules:
- If the variable already exists in any file, **modify** its line/row/description — do not duplicate or leave a stale entry.
- Keep section/category names consistent across all three files (e.g. "AWS SES", "Phishing Detection").
- For dev-only or self-host-only variables, still mention them in the wiki and note the scope; only skip the example file where the variable is genuinely never applicable.
- When in doubt about whether a variable belongs in `apps/api/.env.example` (development), include it commented out with a short note.
## Plugins
There are two plugins installed for you to use.
+16 -7
View File
@@ -1,4 +1,4 @@
![card.png](/assets/card.png)
![card.png](https://www.useplunk.com/api/og?title=Open-Source%0AEmail%20Platform&description=%240.001+per+email.+No+contact+limits.+Free+to+self-host.)
<h1 align="center">Plunk</h1>
@@ -15,23 +15,27 @@
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
<a href="https://docs.useplunk.com"><img src="https://img.shields.io/badge/docs-docs.useplunk.com-black" alt="Documentation"/></a>
<a href="https://useplunk.com/discord"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
</p>
## Introduction
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
applications.
It can be considered as a self-hosted alternative to services
like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
Transactional emails, marketing campaigns, and workflow automation — in one platform. Self-hostable, $0.001 per email, no contact limits.
An open-source, self-hosted alternative to tools [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
## Features
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
- **SMTP**: Use Plunk as an SMTP relay to send emails from any existing tool or framework
- **Campaigns**: Send newsletters and product updates to large audiences
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
- **Segments**: Organize contacts with dynamic filtering and target the right audience
- **Contact Management**: Manage contacts with custom fields and full activity history
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
- **Inbound Emails**: Receive and process incoming emails with custom routing rules
## Sponsors
@@ -46,6 +50,11 @@ You can pull the latest image from [Github](https://github.com/useplunk/plunk/pk
A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
## Community
- **Documentation**: [docs.useplunk.com](https://docs.useplunk.com)
- **Discord**: [useplunk.com/discord](https://useplunk.com/discord)
## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
+58
View File
@@ -8,6 +8,8 @@
# ==============================================================================
NODE_ENV=development
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
# Port the API server listens on (default: 8080)
# PORT=8080
# ==============================================================================
# Application URLs
@@ -25,6 +27,8 @@ LANDING_URI=http://localhost:4000
# ==============================================================================
# API key for authenticating with the Plunk API (obtained from dashboard)
PLUNK_API_KEY=
# From address used for platform notification emails (project disabled, billing limits, etc.)
# PLUNK_FROM_ADDRESS=
# ==============================================================================
# Database & Redis
@@ -61,6 +65,17 @@ SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optiona
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# Email worker concurrency (number of emails processed in parallel)
# If not set, derived from the effective rate limit (~ rate * 0.5, min 5, capped
# by EMAIL_WORKER_MAX_CONCURRENCY). Set this to pin a fixed value when the
# Prisma connection pool or memory is the binding constraint.
# EMAIL_WORKER_CONCURRENCY=10
# Upper bound for auto-derived worker concurrency
# Raise this when your SES quota is high AND the Prisma pool has been sized for it.
# Default: 50
# EMAIL_WORKER_MAX_CONCURRENCY=50
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
@@ -85,3 +100,46 @@ STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe mete
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
# ==============================================================================
# Notifications (Optional - system notifications via ntfy)
# ==============================================================================
# ntfy topic URL for internal system notifications (e.g. project disabled, billing limits).
# When unset, ntfy notifications are disabled.
# Examples:
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
# - Self-hosted: https://your-ntfy-server.com/your-topic
# NTFY_URL=
# ==============================================================================
# Attachments (Optional)
# ==============================================================================
# Limits applied to attachments on transactional emails.
# AWS SES caps total message size at 40 MB; the defaults below leave headroom.
# MAX_ATTACHMENT_SIZE_MB=10
# MAX_ATTACHMENTS_COUNT=10
# ==============================================================================
# User Management (Optional)
# ==============================================================================
# When 'true', the signup endpoint rejects new user registrations.
# Default: false
# DISABLE_SIGNUPS=false
# When 'true', validates emails on signup (disposable domains, plus-addressing,
# domain existence, MX records).
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ==============================================================================
# Phishing Detection (Optional - AI-powered phishing scan via OpenRouter)
# ==============================================================================
# When OPENROUTER_API_KEY is set, a random sample of outgoing emails is
# analyzed by an LLM. Projects can be auto-disabled if a single email exceeds
# PHISHING_CONFIDENCE_THRESHOLD, or if PHISHING_CUMULATIVE_THRESHOLD emails are
# flagged within PHISHING_CUMULATIVE_WINDOW_MS.
# OPENROUTER_API_KEY=
# OPENROUTER_MODEL=anthropic/claude-3-haiku
# PHISHING_DETECTION_SAMPLE_RATE=0.1
# PHISHING_CONFIDENCE_THRESHOLD=95
# PHISHING_CUMULATIVE_THRESHOLD=3
# PHISHING_CUMULATIVE_WINDOW_MS=3600000
+4
View File
@@ -36,8 +36,10 @@
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"mailparser": "^3.9.8",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"sanitize-html": "^2.17.3",
"signale": "^1.4.0",
"stripe": "^20.0.0"
},
@@ -48,8 +50,10 @@
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/sanitize-html": "^2.16.1",
"@types/signale": "^1.4.7",
"concurrently": "^9.2.1",
"tsx": "^4.20.6"
+36
View File
@@ -45,6 +45,12 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
// needs MX + TXT records that can't coexist with a CNAME.
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
// Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing)
@@ -52,6 +58,20 @@ export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECO
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Email Worker Concurrency (optional override)
// If not set, concurrency is derived from the effective rate limit so a higher
// SES quota actually translates into higher throughput. Set this to pin a fixed
// value (useful when Prisma pool size or memory is the binding constraint).
export const EMAIL_WORKER_CONCURRENCY = process.env.EMAIL_WORKER_CONCURRENCY
? Number(process.env.EMAIL_WORKER_CONCURRENCY)
: undefined;
// Upper bound for auto-derived concurrency. Raise this if you have a large SES
// quota AND have sized the Prisma connection pool accordingly.
export const EMAIL_WORKER_MAX_CONCURRENCY = process.env.EMAIL_WORKER_MAX_CONCURRENCY
? Number(process.env.EMAIL_WORKER_MAX_CONCURRENCY)
: 50;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
@@ -110,8 +130,24 @@ export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
// Controls whether email validation checks are performed on signup (default: false)
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
// Attachment Limits (optional)
// Maximum total attachment size in MB (default: 10). AWS SES supports up to 40 MB.
export const MAX_ATTACHMENT_SIZE_MB = Number(validateEnv('MAX_ATTACHMENT_SIZE_MB', '10'));
// Maximum number of attachments per email (default: 10)
export const MAX_ATTACHMENTS_COUNT = Number(validateEnv('MAX_ATTACHMENTS_COUNT', '10'));
// 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
// Phishing Detection (optional)
// OpenRouter API integration for content safety checks
export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', '');
export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku');
export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails
export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== '';
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % required to auto-disable project from a single detection
export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3)
export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour)
+1 -1
View File
@@ -120,7 +120,7 @@ export class Actions {
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
* - attachments: array (optional) - Email attachments (configurable via MAX_ATTACHMENTS_COUNT / MAX_ATTACHMENT_SIZE_MB, defaults: 10 / 10MB)
* - filename: string (required) - Attachment filename
* - content: string (required) - Base64 encoded file content
* - contentType: string (required) - MIME type (e.g., "application/pdf")
+2
View File
@@ -64,6 +64,7 @@ export class Campaigns {
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined;
const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -74,6 +75,7 @@ export class Campaigns {
const result = await CampaignService.list(auth.projectId, {
status,
search,
page,
pageSize,
});
+2
View File
@@ -8,6 +8,7 @@ import {
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
MAIL_FROM_SUBDOMAIN,
NODE_ENV,
S3_ENABLED,
SMTP_DOMAIN,
@@ -62,6 +63,7 @@ export class Config {
},
aws: {
sesRegion: AWS_SES_REGION,
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
},
});
}
+62 -73
View File
@@ -1,6 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import {ContactSchemas} from '@plunk/shared';
import type {BulkContactActionSelector} from '@plunk/types';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
@@ -273,6 +275,30 @@ export class Contacts {
});
}
/**
* POST /contacts/lookup
* Bulk-check which emails already exist in the project (max 500)
*/
@Post('lookup')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async lookup(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {emails} = req.body as {emails: string[]};
if (!Array.isArray(emails) || emails.length === 0) {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
if (emails.length > 500) {
return res.status(400).json({error: 'Maximum 500 emails per lookup'});
}
const result = await ContactService.lookup(auth.projectId!, emails);
return res.status(200).json(result);
}
/**
* POST /contacts/import
* Import contacts from CSV file
@@ -400,31 +426,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
// Validate limit
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
return res.status(202).json({
message: 'Bulk subscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
});
}
return queueBulkAction(req, res, 'subscribe');
}
/**
@@ -435,30 +437,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
return res.status(202).json({
message: 'Bulk unsubscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
});
}
return queueBulkAction(req, res, 'unsubscribe');
}
/**
@@ -469,30 +448,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
return res.status(202).json({
message: 'Bulk delete queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
});
}
return queueBulkAction(req, res, 'delete');
}
/**
@@ -526,3 +482,36 @@ export class Contacts {
}
}
}
async function queueBulkAction(
req: Request,
res: Response,
operation: 'subscribe' | 'unsubscribe' | 'delete',
) {
const auth = res.locals.auth;
const parsed = ContactSchemas.bulkAction.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
error: parsed.error.errors[0]?.message ?? 'Invalid bulk action payload',
});
}
const selector: BulkContactActionSelector =
parsed.data.mode === 'ids'
? {mode: 'ids', contactIds: parsed.data.contactIds}
: {mode: 'query', filter: parsed.data.filter, excludeIds: parsed.data.excludeIds};
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, selector, operation);
return res.status(202).json({
message: `Bulk ${operation} queued successfully`,
jobId: job.id,
});
} catch (error) {
signale.error(`[CONTACTS] Failed to queue bulk ${operation}:`, error);
return res.status(500).json({
error: error instanceof Error ? error.message : `Failed to queue bulk ${operation}`,
});
}
}
+49 -24
View File
@@ -3,11 +3,12 @@ import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {NotAllowed, NotFound} from '../exceptions/index.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
@@ -16,16 +17,12 @@ export class Domains {
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated, requireEmailVerified])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
public async getProjectDomains(_req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
const domains = await DomainService.getProjectDomains(auth.projectId!);
return res.status(200).json(domains);
}
@@ -34,23 +31,44 @@ export class Domains {
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated, requireEmailVerified])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
const {domain} = DomainSchemas.create.parse(req.body);
const projectId = auth.projectId!;
if (!auth.userId) {
throw new NotFound('User authentication required');
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, projectId);
}
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(projectId);
if (isDisabled) {
throw new NotAllowed(
'This project has been disabled. Please contact support for assistance.',
);
}
// Block subdomains whose root domain belongs to a disabled project
const rootCheck = await DomainService.checkSubdomainOfDisabledRoot(domain);
if (rootCheck.blocked) {
throw new NotAllowed(
'This domain cannot be added at this time. Please contact support for assistance.',
);
}
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) {
if (ownershipCheck.projectId === projectId) {
return res.status(400).json({
error: 'This domain is already linked to this project.',
});
}
// If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) {
return res.status(400).json({
@@ -82,7 +100,7 @@ export class Domains {
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated, requireEmailVerified])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -90,13 +108,10 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain) {
if (!domain || domain.projectId !== auth.projectId) {
throw new NotFound('Domain not found');
}
// Verify user has access to the project this domain belongs to
await MembershipService.requireAccess(auth.userId!, domain.projectId);
const verificationStatus = await DomainService.checkVerification(id);
// Invalidate cache if status changed
@@ -110,7 +125,7 @@ export class Domains {
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated, requireEmailVerified])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
@@ -118,12 +133,22 @@ export class Domains {
const domain = await DomainService.id(id);
if (!domain) {
if (!domain || domain.projectId !== auth.projectId) {
throw new NotFound('Domain not found');
}
// Verify user has admin access to the project this domain belongs to
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
// Require admin role for JWT users (API keys bypass — project-scoped by design)
if (auth.type === 'jwt') {
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
}
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
if (isDisabled) {
throw new NotAllowed(
'This project has been disabled. Please contact support for assistance.',
);
}
await DomainService.removeDomain(id);
+2 -2
View File
@@ -155,7 +155,7 @@ export class Segments {
public async addMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const {emails} = req.body;
const {emails, createMissing, subscribed} = req.body as {emails: string[]; createMissing?: boolean; subscribed?: boolean};
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
@@ -165,7 +165,7 @@ export class Segments {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails, createMissing ?? false, subscribed ?? true);
return res.status(200).json(result);
}
+11 -3
View File
@@ -68,7 +68,7 @@ export class Users {
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
`You cannot create new projects at this time. Please contact support for assistance.`,
ErrorCode.PROJECT_DISABLED,
);
}
@@ -230,6 +230,14 @@ export class Users {
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
custom_fields: [
{
key: 'promo_code',
label: {type: 'custom', custom: 'Promo code'},
type: 'text',
optional: true,
},
],
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
@@ -615,7 +623,7 @@ export class Users {
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -703,7 +711,7 @@ export class Users {
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
+154 -5
View File
@@ -2,6 +2,8 @@ import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import {simpleParser} from 'mailparser';
import sanitizeHtml from 'sanitize-html';
import signale from 'signale';
import type Stripe from 'stripe';
@@ -35,6 +37,13 @@ export class Webhooks {
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Verify SNS message signature before processing anything
const signatureValid = await SecurityService.verifySnsSignature(req.body as Record<string, string>);
if (!signatureValid) {
signale.warn('[WEBHOOK] SNS signature verification failed — request rejected');
return res.status(403).json({success: false, message: 'Invalid SNS signature'});
}
// Handle SNS subscription confirmation FIRST (before parsing Message field)
if (req.body.Type === 'SubscriptionConfirmation') {
signale.info('SNS Subscription Confirmation received');
@@ -146,6 +155,39 @@ export class Webhooks {
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Parse email content if available
let htmlBody: string | undefined;
if (body.content && typeof body.content === 'string') {
try {
const isBase64 = body.receipt?.action?.encoding === 'BASE64';
const emailBuffer = isBase64
? Buffer.from(body.content, 'base64')
: Buffer.from(body.content);
const parsed = await simpleParser(emailBuffer);
const raw =
(parsed.html ? String(parsed.html) : undefined) ??
parsed.textAsHtml ??
parsed.text ??
undefined;
if (raw) {
htmlBody = sanitizeHtml(raw, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'width', 'height'],
'*': ['style'],
},
allowedSchemes: ['http', 'https', 'mailto'],
});
}
} catch (parseError) {
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
}
}
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
@@ -171,13 +213,13 @@ export class Webhooks {
);
}
// Create an Email record for tracking (no actual email content since it's inbound)
// Create an Email record for tracking with parsed content
const inboundEmail = await prisma.email.create({
data: {
projectId: domainRecord.projectId,
contactId: contact!.id,
subject: body.mail?.commonHeaders?.subject || '(No subject)',
body: '', // Inbound emails don't have body content in our system
body: htmlBody || '', // Store HTML body in the body field
from: recipientEmail, // The recipient address that received the email
sourceType: EmailSourceType.INBOUND,
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
@@ -197,7 +239,7 @@ export class Webhooks {
);
}
// Prepare event data with all inbound email details
// Prepare event data with all inbound email details including body content
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
@@ -207,6 +249,8 @@ export class Webhooks {
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Email body content
body: htmlBody,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
@@ -273,6 +317,7 @@ export class Webhooks {
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
emailId: email.id,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
@@ -485,10 +530,24 @@ export class Webhooks {
},
});
// Base onboarding credit: refund the 1-unit card-verification charge
let creditBalance = -100;
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
// into the Promo code custom field on Stripe Checkout.
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
const promoCode = promoField?.text?.value?.trim().toUpperCase();
if (promoCode === 'SWITCH') {
creditBalance -= 200;
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
} else if (promoCode) {
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
}
// Update Stripe customer name to match project name and add credit for onboarding fee
await stripe.customers.update(customerId, {
name: updatedProject.name,
balance: -100,
balance: creditBalance,
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
@@ -514,6 +573,16 @@ export class Webhooks {
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Re-enable the project only if it was previously disabled for a failed payment.
// Projects disabled for other reasons (reputation, phishing, manual) must stay disabled.
if (project.disabled && project.disabledReason === 'PAYMENT_FAILED') {
await prisma.project.update({
where: {id: project.id},
data: {disabled: false, disabledReason: null},
});
signale.success(`[WEBHOOK] Project ${project.name} (${project.id}) re-enabled after payment`);
}
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
@@ -547,7 +616,7 @@ export class Webhooks {
await prisma.project.update({
where: {id: project.id},
data: {disabled: true},
data: {disabled: true, disabledReason: 'PAYMENT_FAILED'},
});
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
@@ -626,6 +695,86 @@ export class Webhooks {
break;
}
case 'radar.early_fraud_warning.created': {
const warning = event.data.object as Stripe.Radar.EarlyFraudWarning;
const chargeId = typeof warning.charge === 'string' ? warning.charge : warning.charge?.id;
if (!chargeId) {
signale.warn('[WEBHOOK] radar.early_fraud_warning.created missing charge ID');
break;
}
signale.warn(`[WEBHOOK] Early fraud warning received for charge ${chargeId} (${warning.fraud_type})`);
// Retrieve the charge to get card fingerprint and customer email
const charge = await stripe.charges.retrieve(chargeId, {
expand: ['payment_method_details', 'billing_details'],
});
const cardFingerprint = charge.payment_method_details?.card?.fingerprint ?? null;
const customerEmail = charge.billing_details?.email ?? null;
// Refund the charge
try {
await stripe.refunds.create({charge: chargeId});
signale.success(`[WEBHOOK] Refunded charge ${chargeId} due to early fraud warning`);
} catch (refundError) {
signale.error(`[WEBHOOK] Failed to refund charge ${chargeId}:`, refundError);
}
// Add card fingerprint and email to Stripe Radar blocklist value lists
if (cardFingerprint) {
try {
const lists = await stripe.radar.valueLists.list({alias: 'blocked_card_fingerprints'});
let listId: string;
const existingList = lists.data[0];
if (existingList) {
listId = existingList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_card_fingerprints',
name: 'Blocked Card Fingerprints',
item_type: 'card_fingerprint',
});
listId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: listId, value: cardFingerprint});
signale.success(`[WEBHOOK] Added card fingerprint ${cardFingerprint} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add card fingerprint to Radar blocklist:`, blocklistError);
}
}
if (customerEmail) {
try {
const emailLists = await stripe.radar.valueLists.list({alias: 'blocked_emails'});
let emailListId: string;
const existingEmailList = emailLists.data[0];
if (existingEmailList) {
emailListId = existingEmailList.id;
} else {
const newList = await stripe.radar.valueLists.create({
alias: 'blocked_emails',
name: 'Blocked Emails',
item_type: 'email',
});
emailListId = newList.id;
}
await stripe.radar.valueListItems.create({value_list: emailListId, value: customerEmail});
signale.success(`[WEBHOOK] Added email ${customerEmail} to Radar blocklist`);
} catch (blocklistError) {
signale.error(`[WEBHOOK] Failed to add email to Radar blocklist:`, blocklistError);
}
}
await NtfyService.notifyEarlyFraudWarning(chargeId, warning.fraud_type, cardFingerprint, customerEmail);
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
+26 -1
View File
@@ -149,6 +149,26 @@ export class Workflows {
return res.status(204).send();
}
/**
* POST /workflows/:id/duplicate
* Duplicate a workflow (always disabled, no execution state)
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
return res.status(201).json(workflow);
}
/**
* POST /workflows/:id/steps
* Add a step to a workflow
@@ -219,12 +239,17 @@ export class Workflows {
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const splice = req.query.splice === 'true';
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
if (splice) {
await WorkflowService.spliceStep(auth.projectId!, workflowId, stepId);
} else {
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
}
return res.status(204).send();
}
@@ -1,6 +1,7 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
import {coerceCustomValue} from '../import-processor.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
@@ -279,3 +280,51 @@ describe('Contact Import - Subscription Status Preservation', () => {
});
});
});
describe('coerceCustomValue', () => {
describe('boolean coercion', () => {
it.each(['true', 'TRUE', 'True', ' true ', 'yes', 'YES', 'Yes'])('coerces %j to true', value => {
expect(coerceCustomValue(value)).toBe(true);
});
it.each(['false', 'FALSE', 'False', ' false ', 'no', 'NO', 'No'])('coerces %j to false', value => {
expect(coerceCustomValue(value)).toBe(false);
});
});
describe('number coercion', () => {
it.each([
['42', 42],
['-7', -7],
['3.14', 3.14],
[' 42 ', 42],
['0.5', 0.5],
['-0.25', -0.25],
['0', 0],
['1', 1],
])('coerces %j to %j', (value, expected) => {
expect(coerceCustomValue(value)).toBe(expected);
});
it.each(['01234', '+42', '.5', '42.', '1e10', 'NaN', 'Infinity', '1.2.3', '4-2'])(
'leaves %j as a string (preserves IDs / rejects loose formats)',
value => {
expect(coerceCustomValue(value)).toBe(value);
},
);
it('"1.0" is a number (does not match the boolean truthy set)', () => {
expect(coerceCustomValue('1.0')).toBe(1);
});
});
describe('passthrough', () => {
it.each(['Alice', 'true!', 'yesno', 'maybe'])('leaves %j as a string', value => {
expect(coerceCustomValue(value)).toBe(value);
});
it('leaves empty string as empty string', () => {
expect(coerceCustomValue('')).toBe('');
});
});
});
+122 -49
View File
@@ -3,73 +3,149 @@
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import type {BulkContactActionJobData} from '@plunk/types';
import {Prisma} from '@plunk/db';
import type {BulkContactActionJobData, BulkContactActionSelector} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
const BATCH_SIZE = 100;
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
/** Contacts whose state was actually changed by this run. */
successCount: number;
/** Subscribe/unsubscribe only: contacts already in the target state. */
unchangedCount: number;
/** Contacts that errored or weren't found (e.g. wrong project). */
failureCount: number;
errors: {contactId: string; email: string; error: string}[];
}
function buildQueryWhere(projectId: string, selector: Extract<BulkContactActionSelector, {mode: 'query'}>): Prisma.ContactWhereInput {
const search = selector.filter?.search;
const excludeIds = selector.excludeIds ?? [];
return {
projectId,
...(search ? {email: {contains: search, mode: 'insensitive' as const}} : {}),
...(excludeIds.length > 0 ? {id: {notIn: excludeIds}} : {}),
};
}
async function applyBatch(
projectId: string,
operation: BulkActionResult['operation'],
ids: string[],
): Promise<{changed: number; unchanged: number}> {
switch (operation) {
case 'subscribe': {
const r = await ContactService.bulkSubscribe(projectId, ids);
return {changed: r.updated, unchanged: r.unchanged};
}
case 'unsubscribe': {
const r = await ContactService.bulkUnsubscribe(projectId, ids);
return {changed: r.updated, unchanged: r.unchanged};
}
case 'delete': {
const r = await ContactService.bulkDelete(projectId, ids);
return {changed: r.deleted, unchanged: 0};
}
}
}
export function createBulkContactWorker() {
const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => {
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const {projectId, operation, selector} = job.data;
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
totalRequested: 0,
successCount: 0,
unchangedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
if (selector.mode === 'ids') {
const {contactIds} = selector;
result.totalRequested = contactIds.length;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts (ids mode) in project ${projectId}`,
);
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
const batchIds = contactIds.slice(i, i + BATCH_SIZE);
try {
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
result.successCount += changed;
result.unchangedCount += unchanged;
const failed = batchIds.length - changed - unchanged;
if (failed > 0) result.failureCount += failed;
} catch (error) {
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
email: '',
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
await job.updateProgress(Math.round(((i + batchIds.length) / contactIds.length) * 100));
}
} else {
const where = buildQueryWhere(projectId, selector);
const total = await prisma.contact.count({where});
result.totalRequested = total;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${total} contacts (query mode) in project ${projectId}`,
);
if (total === 0) {
await job.updateProgress(100);
return result;
}
// Cursor-based iteration over matching contacts. We re-evaluate the where clause
// each batch (with id < cursor) instead of Prisma's `cursor:` because for `delete`
// the rows we just processed disappear — a stable cursor would either skip survivors
// or revisit deletions. Sorting by id desc + `id < lastId` is idempotent under either.
let lastId: string | undefined;
let processedRows = 0;
// Cap the loop so a runaway query (e.g. growing table) can't spin forever.
const maxIterations = Math.ceil(total / BATCH_SIZE) + 50;
for (let iter = 0; iter < maxIterations; iter += 1) {
const batch = await prisma.contact.findMany({
where: {
...where,
...(lastId ? {id: {...(where.id as object | undefined), lt: lastId}} : {}),
},
select: {id: true},
orderBy: {id: 'desc'},
take: BATCH_SIZE,
});
if (batch.length === 0) break;
const batchIds = batch.map(c => c.id);
lastId = batchIds[batchIds.length - 1];
try {
let batchResult: {updated?: number; deleted?: number};
switch (operation) {
case 'subscribe':
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'unsubscribe':
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'delete':
batchResult = await ContactService.bulkDelete(projectId, batchIds);
result.successCount += batchResult.deleted || 0;
break;
}
// If some contacts in batch weren't processed, track them as failures
const processedCount = batchResult.updated || batchResult.deleted || 0;
const failedCount = batchIds.length - processedCount;
if (failedCount > 0) {
result.failureCount += failedCount;
// Note: We don't have individual contact details for batch failures
}
const {changed, unchanged} = await applyBatch(projectId, operation, batchIds);
result.successCount += changed;
result.unchangedCount += unchanged;
const failed = batchIds.length - changed - unchanged;
if (failed > 0) result.failureCount += failed;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
signale.error('[BULK-CONTACT-PROCESSOR] Batch failed:', error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
@@ -78,24 +154,21 @@ export function createBulkContactWorker() {
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
processedRows += batchIds.length;
await job.updateProgress(Math.min(100, Math.round((processedRows / total) * 100)));
if (batch.length < BATCH_SIZE) break;
}
signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
return result;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
throw error;
}
signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
return result;
},
{
connection: bulkContactQueue.opts.connection,
concurrency: 3, // Process max 3 bulk operations concurrently
concurrency: 3,
},
);
+70 -52
View File
@@ -3,17 +3,24 @@
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {SendEmailJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
import {
DASHBOARD_URI,
EMAIL_RATE_LIMIT_PER_SECOND,
EMAIL_WORKER_CONCURRENCY,
EMAIL_WORKER_MAX_CONCURRENCY,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue} from '../services/QueueService.js';
import {SecurityService} from '../services/SecurityService.js';
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
/**
@@ -45,9 +52,31 @@ async function getEmailRateLimit(): Promise<number> {
return DEFAULT_RATE_LIMIT;
}
/**
* Derive worker concurrency from the rate limit so a higher SES quota actually
* translates into higher throughput. The mean job duration is ~0.5s (Prisma
* reads + HTML compile + SES call + writes), so `rate * 0.5` gives ~2× headroom
* over the per-second cap. Clamped to keep sandbox accounts useful and to
* protect the Prisma pool on very large quotas.
*/
function deriveWorkerConcurrency(rateLimit: number): number {
if (EMAIL_WORKER_CONCURRENCY !== undefined) {
return EMAIL_WORKER_CONCURRENCY;
}
const TARGET_JOB_SECONDS = 0.5;
const MIN_CONCURRENCY = 5;
const derived = Math.ceil(rateLimit * TARGET_JOB_SECONDS);
return Math.max(MIN_CONCURRENCY, Math.min(derived, EMAIL_WORKER_MAX_CONCURRENCY));
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const concurrency = deriveWorkerConcurrency(rateLimit);
signale.info(
`[EMAIL-PROCESSOR] Worker concurrency: ${concurrency} (rate limit: ${rateLimit}/s)`,
);
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
@@ -81,6 +110,12 @@ export async function createEmailWorker() {
error: 'Project is disabled',
},
});
// Cancelled emails are terminal for the campaign — finalize so it doesn't
// stay stuck in SENDING forever waiting on emails that will never be sent.
if (email.campaignId) {
await CampaignService.finalizeIfDone(email.campaignId);
}
return;
}
@@ -146,6 +181,36 @@ export async function createEmailWorker() {
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
// Check for phishing/dangerous content before sending
const phishingCheck = await SecurityService.checkPhishingContent(
email.projectId,
email.project.name,
email.from,
formattedEmail.subject,
compiledHtml,
);
if (phishingCheck.shouldDisable) {
// Disable project immediately
await SecurityService.disableProjectForPhishing(
email.projectId,
formattedEmail.subject,
phishingCheck.confidence,
'Phishing content detected',
);
// Mark email as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'This email could not be sent. The project has been disabled. Please contact support.',
},
});
throw new Error(`Project ${email.projectId} has been disabled due to a policy violation`);
}
// Send via AWS SES
const result = await sendRawEmail({
from: {
@@ -188,62 +253,15 @@ export async function createEmailWorker() {
from: email.from,
fromName: email.fromName,
messageId: result.messageId,
emailId: email.id,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
// If this email belongs to a campaign, check if all campaign emails have been sent
if (email.campaignId) {
const campaign = await prisma.campaign.findUnique({
where: {id: email.campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {
select: {name: true},
},
},
});
// Only check if campaign is still in SENDING status
if (campaign && campaign.status === CampaignStatus.SENDING) {
// Count how many emails have been sent for this campaign
const sentCount = await prisma.email.count({
where: {
campaignId: email.campaignId,
sentAt: {not: null},
},
});
// If all emails have been sent, mark campaign as SENT
if (sentCount >= campaign.totalRecipients) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
sentCount,
},
});
signale.success(
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
// Send notification about campaign send completed
const {NtfyService} = await import('../services/NtfyService.js');
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
campaign.totalRecipients,
);
}
}
await CampaignService.finalizeIfDone(email.campaignId);
}
} catch (error) {
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
@@ -262,7 +280,7 @@ export async function createEmailWorker() {
},
{
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
concurrency,
limiter: {
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
+32 -1
View File
@@ -123,7 +123,11 @@ export function createImportWorker() {
// Extract custom data (all fields except email and subscribed)
const {email: _, subscribed: __, ...customData} = record;
const data = Object.keys(customData).length > 0 ? customData : undefined;
const customEntries = Object.entries(customData);
const data =
customEntries.length > 0
? Object.fromEntries(customEntries.map(([k, v]) => [k, coerceCustomValue(v)]))
: undefined;
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email);
@@ -216,3 +220,30 @@ function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Values considered as boolean during import.
// Numbers (0, 1) are intentionally absent.
const BOOLEAN_TRUE = new Set(['true', 'yes']);
const BOOLEAN_FALSE = new Set(['false', 'no']);
// Strict integer-or-decimal number detection pattern.
// Valid: 0, 42, -42, 3.14
// Rejected: 007, +42, 1.2.3, 1e5
const NUMERIC_RE = /^-?(0|[1-9]\d*)(\.\d+)?$/;
/**
* Coerces a raw string into its most natural primitive type: `boolean`,
* `number`, or `string`. Values that match neither are
* returned unchanged.
*
* @param value The raw string to coerce.
* @returns The coerced value as `boolean`, `number`, or `string`.
*/
export function coerceCustomValue(value: string): string | boolean | number {
const trimmed = value.trim();
const lower = trimmed.toLowerCase();
if (BOOLEAN_TRUE.has(lower)) return true;
if (BOOLEAN_FALSE.has(lower)) return false;
if (NUMERIC_RE.test(trimmed)) return Number(trimmed);
return value;
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Background Job: Meter Processor
* Reliably records Stripe billing meter events with automatic retries
*/
import type {MeterEventJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {meterQueue} from '../services/QueueService.js';
async function processMeterEvent(job: Job<MeterEventJobData>): Promise<void> {
const {customerId, value, idempotencyKey} = job.data;
if (!STRIPE_ENABLED || !stripe) {
return;
}
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(),
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER-WORKER] Recorded ${value} email(s) for customer ${customerId}`);
}
export function createMeterWorker(): Worker {
const worker = new Worker<MeterEventJobData>(
meterQueue.name,
async (job: Job<MeterEventJobData>) => {
await processMeterEvent(job);
},
{
connection: meterQueue.opts.connection,
concurrency: 5,
limiter: {
max: 50,
duration: 1000, // Stay well within Stripe's rate limits
},
},
);
worker.on('failed', (job, error) => {
signale.error(`[METER-WORKER] Job ${job?.id} failed (attempt ${job?.attemptsMade}):`, error);
});
worker.on('error', error => {
signale.error('[METER-WORKER] Worker error:', error);
});
return worker;
}
+6
View File
@@ -15,6 +15,7 @@ import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
import {createImportWorker} from './import-processor.js';
import {createMeterWorker} from './meter-processor.js';
import {createScheduledCampaignWorker} from './scheduled-processor.js';
import {createSegmentCountWorker} from './segment-count-processor.js';
import {createWorkflowWorker} from './workflow-processor-queue.js';
@@ -70,6 +71,11 @@ async function startWorkers() {
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
signale.success('[WORKER] API request cleanup worker started');
// Start meter worker
const meterWorker = createMeterWorker();
workers.push({name: 'meter', worker: meterWorker});
signale.success('[WORKER] Meter worker started');
signale.success('[WORKER] All workers started successfully');
} catch (error) {
signale.error('[WORKER] Failed to start workers:', error);
@@ -3,6 +3,20 @@ import type {NextFunction, Request, Response} from 'express';
import {databaseRequestLogger} from '../requestLogger.js';
import {factories, getPrismaClient} from '../../../../../test/helpers';
async function waitForLog(prisma: ReturnType<typeof getPrismaClient>, id: string, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const record = await prisma.apiRequest.findUnique({where: {id}});
if (record) return record;
await new Promise(resolve => setTimeout(resolve, 20));
}
return prisma.apiRequest.findUnique({where: {id}});
}
async function waitForNoLog(ms = 200) {
await new Promise(resolve => setTimeout(resolve, ms));
}
describe('Request Logger Middleware', () => {
const prisma = getPrismaClient();
let req: Partial<Request>;
@@ -77,13 +91,7 @@ describe('Request Logger Middleware', () => {
const responseBody = {success: true, data: {id: '123'}};
await res.json!(responseBody);
// Wait for async logging to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify database record was created
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123');
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.method).toBe('POST');
@@ -109,11 +117,7 @@ describe('Request Logger Middleware', () => {
const responseBody = {success: true};
await res.json!(responseBody);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'public-request-id'},
});
const loggedRequest = await waitForLog(prisma, 'public-request-id');
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.projectId).toBeNull();
@@ -131,11 +135,7 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123');
// Allow for timer imprecision (especially in CI environments)
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45);
@@ -162,11 +162,7 @@ describe('Request Logger Middleware', () => {
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123');
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.statusCode).toBe(400);
@@ -189,11 +185,7 @@ describe('Request Logger Middleware', () => {
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123');
expect(loggedRequest?.statusCode).toBe(500);
expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR');
@@ -209,11 +201,7 @@ describe('Request Logger Middleware', () => {
error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'},
});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
const loggedRequest = await waitForLog(prisma, 'test-request-id-123');
expect(loggedRequest?.statusCode).toBe(404);
expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND');
@@ -306,11 +294,8 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `log-${path.replace(/\//g, '-')}`},
});
const loggedRequest = await waitForLog(prisma, `log-${path.replace(/\//g, '-')}`);
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.path).toBe(path);
@@ -352,17 +337,18 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// Should create a record with generated UUID
const allRequests = await prisma.apiRequest.findMany({
where: {
path: '/v1/send',
method: 'POST',
},
orderBy: {createdAt: 'desc'},
take: 1,
});
// Should create a record with generated UUID — poll for it
const deadline = Date.now() + 2000;
let allRequests: Awaited<ReturnType<typeof prisma.apiRequest.findMany>> = [];
while (Date.now() < deadline) {
allRequests = await prisma.apiRequest.findMany({
where: {path: '/v1/send', method: 'POST'},
orderBy: {createdAt: 'desc'},
take: 1,
});
if (allRequests.length > 0) break;
await new Promise(resolve => setTimeout(resolve, 20));
}
expect(allRequests.length).toBeGreaterThan(0);
expect(allRequests[0].id).toBeDefined();
@@ -418,11 +404,8 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(reqWithSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-size-5000'},
});
const loggedRequest = await waitForLog(prisma, 'test-size-5000');
expect(loggedRequest?.requestSize).toBe(5000);
});
@@ -445,11 +428,8 @@ describe('Request Logger Middleware', () => {
databaseRequestLogger(reqNoSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-no-size'},
});
const loggedRequest = await waitForLog(prisma, 'test-no-size');
expect(loggedRequest?.requestSize).toBeNull();
});
@@ -474,11 +454,8 @@ describe('Request Logger Middleware', () => {
};
await resLarge.json!(largeResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-large-response'},
});
const loggedRequest = await waitForLog(prisma, 'test-large-response');
const expectedSize = JSON.stringify(largeResponse).length;
expect(loggedRequest?.responseSize).toBe(expectedSize);
+4 -4
View File
@@ -135,7 +135,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -204,7 +204,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -271,7 +271,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -315,7 +315,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
+45 -2
View File
@@ -67,10 +67,14 @@ export class ActivityService {
const fetchLimit = effectiveLimit;
// Default date range to last 30 days if not specified
// IMPORTANT: When cursor is provided (pagination), we should NOT apply the gte constraint
// to allow users to paginate back beyond the initial date range
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
// Only apply start date filter on initial load (no cursor)
// This allows pagination to go back indefinitely
...(cursor ? {} : {gte: startDate || defaultStartDate}),
...(endDate ? {lte: endDate} : {}),
};
@@ -218,11 +222,34 @@ export class ActivityService {
}
}
/**
* Short Redis TTL for recent-count results. Short enough to feel live on the
* dashboard live-pulse, long enough to absorb the polling load when many
* tabs are open across the user base.
*/
private static readonly RECENT_COUNT_CACHE_TTL = 10; // seconds
/**
* Get recent activity count (for real-time updates)
* Returns count of activities in the last N minutes
*
* Backed by a short Redis cache because the dashboard polls this endpoint
* every 30 seconds per open tab; without the cache, every poll would run
* three COUNT queries against the events, emails, and workflow_executions
* tables.
*/
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
const cacheKey = Keys.Activity.recentCount(projectId, minutes);
try {
const cached = await redis.get(cacheKey);
if (cached !== null) {
return parseInt(cached, 10);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to read recent-count cache:', error);
}
const since = new Date(Date.now() - minutes * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {gte: since};
@@ -241,7 +268,15 @@ export class ActivityService {
}),
]);
return eventCount + emailCount + workflowCount;
const total = eventCount + emailCount + workflowCount;
try {
await redis.setex(cacheKey, this.RECENT_COUNT_CACHE_TTL, total.toString());
} catch (error) {
signale.warn('[ACTIVITY] Failed to cache recent-count:', error);
}
return total;
}
/**
@@ -364,6 +399,14 @@ export class ActivityService {
const where: Prisma.EmailWhereInput = {
projectId,
...(contactId ? {contactId} : {}),
// Apply cursor-based pagination filter on createdAt
// This is critical for pagination to work correctly
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
};
// Build OR conditions to filter by the appropriate timestamp field for each activity type
+82 -4
View File
@@ -1,5 +1,5 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
@@ -186,16 +186,26 @@ export class CampaignService {
projectId: string,
options: {
status?: CampaignStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options;
const {status, search, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = {
projectId,
...(status ? {status} : {}),
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{subject: {contains: search, mode: 'insensitive' as const}},
{from: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
};
const [campaigns, total] = await Promise.all([
@@ -505,9 +515,77 @@ export class CampaignService {
limit,
cursor: nextCursor,
});
} else {
// Last batch: reconcile totalRecipients to the actual number of emails created.
// Dynamic segments re-evaluate on each batch query, so contacts that left the
// segment after totalRecipients was calculated are silently skipped. Without this
// reconciliation, sentCount can never reach the original totalRecipients and the
// campaign remains stuck in SENDING forever.
const actualEmailCount = await prisma.email.count({where: {campaignId}});
await prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: actualEmailCount},
});
await this.finalizeIfDone(campaignId);
}
}
/**
* Finalize a SENDING campaign if every email has reached a terminal state.
* Terminal = sentAt is set OR status is FAILED. Counting FAILED as terminal
* unsticks campaigns where some emails couldn't be delivered (e.g. the project
* was disabled mid-send), so the campaign moves to SENT with a partial sentCount.
*/
public static async finalizeIfDone(campaignId: string): Promise<void> {
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {select: {name: true}},
},
});
if (!campaign || campaign.status !== CampaignStatus.SENDING) {
return;
}
const [processedCount, sentCount] = await Promise.all([
prisma.email.count({
where: {
campaignId,
OR: [{sentAt: {not: null}}, {status: EmailStatus.FAILED}],
},
}),
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
]);
if (campaign.totalRecipients > 0 && processedCount < campaign.totalRecipients) {
return;
}
await prisma.campaign.update({
where: {id: campaignId},
data: {status: CampaignStatus.SENT, sentCount},
});
signale.success(
`[CAMPAIGN] Campaign ${campaign.name} finalized: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
sentCount,
);
}
/**
* Cancel a campaign
*/
@@ -738,7 +816,7 @@ export class CampaignService {
}
// Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition);
const segmentWhere = await SegmentService.buildConditionClause(condition);
return {
...baseWhere,
@@ -781,7 +859,7 @@ export class CampaignService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
const segmentWhere = await SegmentService.buildConditionClause(condition);
return {
...baseWhere,
+136 -123
View File
@@ -76,6 +76,23 @@ export class ContactService {
return contact;
}
/**
* Bulk-check which emails exist in the project — single query, safe for up to 500 addresses.
*/
public static async lookup(
projectId: string,
emails: string[],
): Promise<{found: string[]; notFound: string[]}> {
const rows = await prisma.contact.findMany({
where: {projectId, email: {in: emails, mode: 'insensitive'}},
select: {email: true},
});
const foundSet = new Set(rows.map(r => r.email.toLowerCase()));
const found = emails.filter(e => foundSet.has(e.toLowerCase()));
const notFound = emails.filter(e => !foundSet.has(e.toLowerCase()));
return {found, notFound};
}
/**
* Find a contact by email (returns null if not found)
*/
@@ -118,6 +135,47 @@ export class ContactService {
* Update a contact
* Uses unique constraint violation to check for duplicates (more efficient)
*/
/**
* Merge an incoming partial data object into existing contact data.
* - `null` value on a key deletes that key
* - empty strings are ignored
* - reserved/system-generated keys are silently filtered
* - `{value, persistent: false}` entries are skipped (non-persistent)
*/
private static mergeContactData(
existing: Prisma.JsonValue | null,
incoming: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> =
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
for (const [key, value] of Object.entries(incoming)) {
if (reservedFields.includes(key)) continue;
if (value === '') continue;
if (value === null) {
delete merged[key];
continue;
}
if (key === 'locale' && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
(value as {persistent: unknown}).persistent === false
) {
continue;
}
merged[key] = value;
}
return merged;
}
public static async update(
projectId: string,
contactId: string,
@@ -132,7 +190,14 @@ export class ContactService {
updateData.email = data.email;
}
if (data.data !== undefined) {
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
if (data.data === null) {
updateData.data = Prisma.JsonNull;
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
} else {
throw new HttpException(400, 'data must be an object');
}
}
if (data.subscribed !== undefined) {
updateData.subscribed = data.subscribed;
@@ -208,90 +273,56 @@ export class ContactService {
},
});
// Process data to merge with existing data
let mergedData: Record<string, unknown> = {};
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
// Start with existing data
mergedData = {...existing.data};
}
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = [
'plunk_id',
'plunk_email',
'id',
'email',
'unsubscribeUrl',
'subscribeUrl',
'manageUrl',
];
if (reservedFields.includes(key)) {
continue;
}
// Validate locale field (special user-settable field)
// Only validate type - any locale string is accepted since we default to English if unsupported
if (key === 'locale') {
if (value !== null && value !== undefined && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
// Non-persistent fields are not stored in contact data
// They would be used only for the current operation (like email template rendering)
continue;
}
// Store the value
mergedData[key] = value;
}
}
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
if (existing) {
// Track subscription status change
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
try {
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
}
}
}
return updated;
return updated;
} catch (error) {
// Provide helpful error message for database/validation issues
throw new HttpException(
500,
`Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
} else {
return prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
try {
return await prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
} catch (error) {
// Provide helpful error message for database/validation issues
throw new HttpException(
500,
`Failed to create contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}
@@ -676,99 +707,81 @@ export class ContactService {
/**
* Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches
* Updates multiple contacts to subscribed=true in batches.
* `updated` = contacts flipped from unsubscribed to subscribed.
* `unchanged` = contacts that were already subscribed (no-op, not a failure).
*/
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
// Verify all contacts belong to this project
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
const unchanged = contacts.length - unsubscribedIds.length;
if (unsubscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
where: {id: {in: unsubscribedIds}, projectId},
data: {subscribed: true},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk subscribe events:', error);
}
});
return {updated: result.count};
return {updated: result.count, unchanged};
}
/**
* Bulk unsubscribe contacts
* Bulk unsubscribe contacts.
* `updated` = contacts flipped from subscribed to unsubscribed.
* `unchanged` = contacts that were already unsubscribed (no-op, not a failure).
*/
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
public static async bulkUnsubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number; unchanged: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
where: {id: {in: contactIds}, projectId},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
if (contacts.length === 0) {
return {updated: 0, unchanged: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
const unchanged = contacts.length - subscribedIds.length;
if (subscribedIds.length === 0) {
return {updated: 0};
return {updated: 0, unchanged};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
where: {id: {in: subscribedIds}, projectId},
data: {subscribed: false},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
}
});
return {updated: result.count};
return {updated: result.count, unchanged};
}
/**
+61 -7
View File
@@ -388,6 +388,49 @@ export class DomainService {
return domain;
}
/**
* Extract the registrable root domain (last two labels) from a domain name.
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com"
*/
private static rootDomain(domain: string): string {
const parts = domain.split('.');
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
}
/**
* Check whether the root domain of `domain` is owned by a disabled project.
* Prevents subdomains from being added when the parent domain is flagged.
*/
public static async checkSubdomainOfDisabledRoot(
domain: string,
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
const root = this.rootDomain(domain);
// Only relevant when the submitted domain is actually a subdomain
if (root === domain) {
return {blocked: false};
}
const rootDomainRecord = await prisma.domain.findFirst({
where: {domain: root},
include: {
project: {
select: {id: true, name: true, disabled: true},
},
},
});
if (rootDomainRecord?.project.disabled) {
return {
blocked: true,
projectName: rootDomainRecord.project.name,
projectId: rootDomainRecord.project.id,
};
}
return {blocked: false};
}
/**
* Check if a domain is already linked to another project
* Used when adding a new domain to verify if the user has access to the existing project
@@ -395,15 +438,14 @@ export class DomainService {
* @param userId User ID to check membership
* @returns Object with exists flag and membership info
*/
public static async checkDomainOwnership(domain: string, userId: string) {
public static async checkDomainOwnership(domain: string, userId?: string) {
const existingDomain = await prisma.domain.findFirst({
where: {domain},
include: {
project: {
include: {
members: {
where: {userId},
},
select: {
id: true,
name: true,
},
},
},
@@ -413,8 +455,20 @@ export class DomainService {
return {exists: false};
}
// Check if user is a member of the project that owns this domain
const isMember = existingDomain.project.members.length > 0;
let isMember = false;
if (userId) {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: existingDomain.project.id,
},
},
});
isMember = membership !== null;
}
return {
exists: true,
+23 -19
View File
@@ -108,7 +108,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
// Queue email for sending
await this.queueEmail(email.id);
await this.queueEmail(email.id, EmailSourceType.TRANSACTIONAL);
return email;
}
@@ -172,7 +172,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id);
await this.queueEmail(email.id, sourceType);
return email;
}
@@ -278,7 +278,7 @@ export class EmailService {
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id);
await this.queueEmail(email.id, sourceType);
return email;
}
@@ -659,12 +659,16 @@ export class EmailService {
/**
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
*
* The TipTap editor loads StarterKit + TextAlign + Color + TextStyle + Link +
* ResizableImage + VariableMention. TextStyle/Color/Link round-trip <span style="..."> and
* <a style="..."> markup. This detection therefore PERMITS span + inline styles and only
* REJECTS markup TipTap cannot represent (tables, divs, forms, embeds, custom attrs,
* <style> blocks, etc).
*/
private static detectCustomHtmlPatterns(html: string): boolean {
if (!html || html.trim() === '') return false;
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
let hasCustomClasses = false;
for (const match of classMatches) {
@@ -679,21 +683,21 @@ export class EmailService {
}
}
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
// Element-attribute-scoped regex; the leading [\s"'] guard prevents `id=` inside
// href URLs (e.g. `?id=...`) from false-matching as an HTML id attribute.
const hasCustomAttributes = /<[a-z][^>]*?[\s"'](?:data-|aria-|role=|id=)/i.test(html);
// Elements TipTap cannot round-trip with the currently-loaded extension set.
// <span> is intentionally excluded -- TipTap's TextStyle extension handles it.
const hasCustomElements =
/<(?:div|section|article|header|footer|nav|aside|main|table|tr|td|th|tbody|thead|tfoot|colgroup|col|form|input|button|select|textarea|iframe|video|audio|svg|object|embed|details|summary|dialog)\b/i.test(
html,
);
const hasMediaQueries = /@media/i.test(html);
const hasStyleTags = /<style[^>]*>/i.test(html);
return (
hasInlineStyles ||
hasCustomClasses ||
hasCustomAttributes ||
hasComplexTables ||
hasCustomElements ||
hasMediaQueries ||
hasStyleTags
);
return hasCustomClasses || hasCustomAttributes || hasCustomElements || hasMediaQueries || hasStyleTags;
}
/**
@@ -1133,7 +1137,7 @@ export class EmailService {
* Queue an email for sending
* Adds email to the BullMQ queue for processing by workers
*/
private static async queueEmail(emailId: string, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, delay);
private static async queueEmail(emailId: string, sourceType: EmailSourceType, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, sourceType, delay);
}
}
+5 -75
View File
@@ -1,98 +1,28 @@
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {STRIPE_ENABLED} from '../app/constants.js';
import {QueueService} from './QueueService.js';
/**
* Meter Service
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
*/
export class MeterService {
/**
* Record an email sent event to Stripe meter
* This is used for usage-based billing where customers pay per email sent
*
* @param customerId - Stripe customer ID
* @param value - Number of emails sent (default: 1)
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
*/
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
// Skip if billing is disabled (self-hosted mode)
if (!STRIPE_ENABLED || !stripe) {
if (!STRIPE_ENABLED) {
return;
}
// Skip if no customer ID (not subscribed)
if (!customerId) {
return;
}
try {
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(), // Must be a string
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
await QueueService.queueMeterEvent(customerId, value, idempotencyKey);
} catch (error) {
// Log error but don't throw - we don't want billing issues to break email sending
signale.error('[METER] Failed to record email sent event:', error);
// If it's a rate limit error, we might want to retry
if (error instanceof Error && error.message.includes('429')) {
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
}
signale.error('[METER] Failed to enqueue meter event:', error);
}
}
/**
* Record multiple emails in a single batch (for campaign sending)
* More efficient than individual calls when sending bulk emails
*
* @param customerId - Stripe customer ID
* @param count - Number of emails sent in this batch
* @param batchId - Unique identifier for this batch
*/
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
if (count <= 0) return;
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
}
/**
* Get current usage for a customer in the current billing period
* Useful for displaying usage dashboards or implementing soft limits
*
* @param customerId - Stripe customer ID
* @param meterId - The meter ID from Stripe dashboard
* @param startTime - Start of period (Unix timestamp)
* @param endTime - End of period (Unix timestamp)
*/
public static async getUsageSummary(
meterId: string,
customerId: string,
startTime: number,
endTime: number,
): Promise<unknown> {
if (!STRIPE_ENABLED || !stripe) {
return null;
}
try {
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
customer: customerId,
start_time: startTime,
end_time: endTime,
});
return summaries;
} catch (error) {
signale.error('[METER] Failed to get usage summary:', error);
return null;
}
}
}
+22
View File
@@ -799,6 +799,28 @@ export class NtfyService {
});
}
public static async notifyEarlyFraudWarning(
chargeId: string,
fraudType: string,
cardFingerprint: string | null,
customerEmail: string | null,
): Promise<void> {
const details = [
`Charge: ${chargeId}`,
`Fraud type: ${fraudType}`,
cardFingerprint ? `Card fingerprint: ${cardFingerprint}` : null,
customerEmail ? `Customer: ${customerEmail}` : null,
]
.filter(Boolean)
.join(' | ');
await this.sendUrgent('Stripe Early Fraud Warning', `Refunded and blocklisted. ${details}`, [
NtfyTag.SHIELD,
NtfyTag.MONEY,
NtfyTag.SKULL,
]);
}
/**
* Initialize the ntfy service with the configured URL
*/
+103 -6
View File
@@ -1,12 +1,15 @@
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import type {
ApiRequestCleanupJobData,
BulkContactActionJobData,
BulkContactActionSelector,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
MeterEventJobData,
ScheduledCampaignJobData,
SegmentCountJobData,
SendEmailJobData,
@@ -158,20 +161,56 @@ export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contac
},
});
export const meterQueue = new Queue<MeterEventJobData>('meter', {
connection: redisConnection,
defaultJobOptions: {
attempts: 10,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 5000,
removeOnFail: 10000,
},
});
function emailPriorityFor(sourceType: EmailSourceType): number {
switch (sourceType) {
case EmailSourceType.TRANSACTIONAL:
return 1;
case EmailSourceType.WORKFLOW:
return 5;
case EmailSourceType.CAMPAIGN:
return 10;
default:
return 5;
}
}
/**
* Queue Service - Centralized queue management
*/
export class QueueService {
/**
* Add email to queue for sending
* Add email to queue for sending.
*
* Transactional emails jump the queue ahead of workflow and campaign sends
* via BullMQ's priority (lower number = higher precedence). This prevents
* latency-sensitive sends (login codes, password resets) from queuing behind
* large campaign bursts on the shared `email` queue.
*/
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
public static async queueEmail(
emailId: string,
sourceType: EmailSourceType,
delay?: number,
): Promise<Job<SendEmailJobData>> {
return emailQueue.add(
'send-email',
{emailId},
{
delay, // Optional delay in milliseconds
jobId: `email-${emailId}`, // Prevent duplicate jobs
delay,
jobId: `email-${emailId}`,
priority: emailPriorityFor(sourceType),
},
);
}
@@ -313,17 +352,34 @@ export class QueueService {
};
}
/**
* Queue a Stripe meter event for reliable delivery with retries
*/
public static async queueMeterEvent(
customerId: string,
value: number,
idempotencyKey?: string,
): Promise<Job<MeterEventJobData>> {
return meterQueue.add(
'record-meter-event',
{customerId, value, idempotencyKey},
{
jobId: idempotencyKey ? `meter-${idempotencyKey}` : undefined,
},
);
}
/**
* Queue bulk contact action job
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
selector: BulkContactActionSelector,
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{projectId, operation, selector},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
@@ -390,6 +446,7 @@ export class QueueService {
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
meterCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
@@ -400,6 +457,7 @@ export class QueueService {
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
meterQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
@@ -412,6 +470,7 @@ export class QueueService {
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
meter: meterCounts,
};
}
@@ -429,6 +488,7 @@ export class QueueService {
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
meterQueue.pause(),
]);
}
@@ -446,6 +506,7 @@ export class QueueService {
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
meterQueue.resume(),
]);
}
@@ -472,6 +533,8 @@ export class QueueService {
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
meterQueue.clean(gracePeriod * 30, 5000, 'completed'), // Keep 30 days for billing audit
meterQueue.clean(gracePeriod * 30, 10000, 'failed'),
]);
}
@@ -540,6 +603,39 @@ export class QueueService {
}
}
// Mark every still-PENDING email for this project as FAILED. We just stripped
// their queue jobs, so without this they'd sit as PENDING forever and any
// campaign waiting on them would stay stuck in SENDING.
const failed = await prisma.email.updateMany({
where: {projectId, status: EmailStatus.PENDING},
data: {status: EmailStatus.FAILED, error: 'Project is disabled'},
});
if (failed.count > 0) {
signale.info(`[QUEUE] Marked ${failed.count} pending emails as failed for project ${projectId}`);
}
// Finalize any in-flight campaigns. With the orphaned PENDING emails now FAILED
// (terminal), the campaign can move to SENT with a partial sentCount instead of
// staying stuck in SENDING. Reconcile totalRecipients first since the batch
// chain may have been cut short.
const sendingCampaigns = await prisma.campaign.findMany({
where: {projectId, status: CampaignStatus.SENDING},
select: {id: true},
});
if (sendingCampaigns.length > 0) {
const {CampaignService} = await import('./CampaignService.js');
for (const campaign of sendingCampaigns) {
const actualEmailCount = await prisma.email.count({where: {campaignId: campaign.id}});
await prisma.campaign.update({
where: {id: campaign.id},
data: {totalRecipients: actualEmailCount},
});
await CampaignService.finalizeIfDone(campaign.id);
}
}
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
}
@@ -557,6 +653,7 @@ export class QueueService {
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
meterQueue.close(),
]);
}
}
+17 -11
View File
@@ -6,6 +6,7 @@ import {
AWS_SES_REGION,
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
MAIL_FROM_SUBDOMAIN,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED,
@@ -133,21 +134,23 @@ export async function sendRawEmail({
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
}
// Build the additional headers (custom headers + List-Unsubscribe), filtering
// out empties so we never emit a blank line inside the header section.
// Per RFC 5322 §2.1, a blank line terminates the header section, so any blank
// line here would push subsequent headers (notably List-Unsubscribe) into the body.
const extraHeaderLines = [
...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
...(unsubscribeHeader ? [unsubscribeHeader] : []),
];
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';
// Build raw MIME message
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
Content-Type: ${rootContentType}${extraHeaders}
`;
@@ -248,10 +251,13 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
// Verify DKIM for the domain
const DKIM = await ses.verifyDomainDkim({Domain: domain});
// Set custom MAIL FROM domain (plunk.yourdomain.com)
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
// is already in use for something else (e.g., a CNAME to a CDN), since the
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
await ses.setIdentityMailFromDomain({
Identity: domain,
MailFromDomain: `plunk.${domain}`,
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
});
return DKIM.DkimTokens ?? [];
+579 -15
View File
@@ -1,3 +1,5 @@
import crypto from 'crypto';
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
@@ -8,14 +10,25 @@ import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {
AUTO_PROJECT_DISABLE,
DASHBOARD_URI,
LANDING_URI,
OPENROUTER_API_KEY,
OPENROUTER_MODEL,
PHISHING_CONFIDENCE_THRESHOLD,
PHISHING_CUMULATIVE_THRESHOLD,
PHISHING_CUMULATIVE_WINDOW_MS,
PHISHING_DETECTION_ENABLED,
PHISHING_DETECTION_SAMPLE_RATE,
} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
* These limits protect AWS SES reputation and prevent account suspension
*/
const SECURITY_THRESHOLDS = {
// Minimum emails required before enforcing limits (prevents false positives)
// Minimum emails required before enforcing rate-based limits (prevents false positives)
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
@@ -31,13 +44,82 @@ const SECURITY_THRESHOLDS = {
COMPLAINT_ALLTIME_CRITICAL: 0.12,
// Minimum absolute counts (prevents small sample size false positives)
// Both percentage AND absolute count must be exceeded to trigger
// Both percentage AND absolute count must be exceeded to trigger rate-based checks
MIN_BOUNCES_FOR_CRITICAL: 10,
MIN_BOUNCES_FOR_WARNING: 5,
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
// === Absolute count ceilings (new projects only) ===
// These trigger regardless of rate — catches new accounts blasting emails
// before their bounce rate has caught up. Established projects rely on
// rate-based checks only, since high absolute counts at high volume
// (e.g. 100 bounces out of 10K) don't indicate abuse.
//
// Legitimate senders ramp up gradually; spammers blast immediately.
NEW_PROJECT_AGE_DAYS: 30,
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING: 25,
NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL: 50,
NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING: 3,
NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL: 7,
NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING: 10,
NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20,
} as const;
/**
* Redis-based tracking for phishing detections per project
* Tracks timestamp and confidence of recent phishing detections
* Uses sorted sets for efficient time-based filtering
*/
interface PhishingDetection {
timestamp: number;
confidence: number;
subject: string;
}
/**
* Track a phishing detection for cumulative analysis using Redis
*/
async function trackPhishingDetection(projectId: string, confidence: number, subject: string): Promise<void> {
const now = Date.now();
const key = `phishing:detections:${projectId}`;
// Store detection as sorted set member (score = timestamp)
// Value is JSON with confidence and subject
const detection: PhishingDetection = {timestamp: now, confidence, subject};
await redis.zadd(key, now, JSON.stringify(detection));
// Remove detections outside the time window
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
await redis.zremrangebyscore(key, '-inf', cutoff);
// Set TTL to window duration to auto-cleanup old keys
await redis.expire(key, Math.ceil(PHISHING_CUMULATIVE_WINDOW_MS / 1000));
}
/**
* Get count of recent phishing detections for a project from Redis
*/
async function getRecentPhishingCount(projectId: string): Promise<number> {
const now = Date.now();
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
const key = `phishing:detections:${projectId}`;
// Count detections within the time window
const count = await redis.zcount(key, cutoff, '+inf');
return count;
}
/**
* Clear phishing detection history for a project (e.g., after disable)
*/
async function clearPhishingHistory(projectId: string): Promise<void> {
const key = `phishing:detections:${projectId}`;
await redis.del(key);
}
interface RateData {
total: number;
bounces: number;
@@ -50,15 +132,84 @@ interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
twentyFourHour: RateData;
sevenDay: RateData;
allTime: RateData;
isNewProject: boolean;
violations: string[];
warnings: string[];
}
const SNS_CERT_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|cn)$/;
const snsSigningCertCache = new Map<string, string>();
async function fetchSigningCert(certUrl: string): Promise<string> {
const cached = snsSigningCertCache.get(certUrl);
if (cached) return cached;
const response = await fetch(certUrl);
if (!response.ok) throw new Error(`Failed to fetch SNS signing cert: ${response.statusText}`);
const pem = await response.text();
snsSigningCertCache.set(certUrl, pem);
return pem;
}
function buildSnsStringToSign(message: Record<string, string>): string {
const fields =
message['Type'] === 'Notification'
? ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']
: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'];
return fields
.filter(key => message[key] !== undefined)
.map(key => `${key}\n${message[key]}\n`)
.join('');
}
export class SecurityService {
private static readonly CACHE_TTL = 300; // 5 minutes
/**
* Verify an AWS SNS message signature. Returns false if the cert URL is
* untrusted, or the signature doesn't match.
*/
public static async verifySnsSignature(body: Record<string, string>): Promise<boolean> {
try {
const certUrl = body['SigningCertURL'];
const signature = body['Signature'];
if (!certUrl || !signature) {
signale.warn('[SNS] Missing SigningCertURL or Signature');
return false;
}
let parsedUrl: URL;
try {
parsedUrl = new URL(certUrl);
} catch {
signale.warn('[SNS] Unparseable SigningCertURL');
return false;
}
if (parsedUrl.protocol !== 'https:' || !SNS_CERT_HOST_RE.test(parsedUrl.hostname)) {
signale.warn(`[SNS] Untrusted SigningCertURL host: ${parsedUrl.hostname}`);
return false;
}
const pem = await fetchSigningCert(certUrl);
const stringToSign = buildSnsStringToSign(body);
const algorithm = body['SignatureVersion'] === '2' ? 'RSA-SHA256' : 'RSA-SHA1';
const verifier = crypto.createVerify(algorithm);
verifier.update(stringToSign, 'utf8');
return verifier.verify(pem, signature, 'base64');
} catch (err) {
signale.error('[SNS] Signature verification error:', err);
return false;
}
}
/**
* Get security status for a project (with caching)
*/
@@ -86,6 +237,13 @@ export class SecurityService {
projectId,
isHealthy: true,
shouldDisable: false,
twentyFourHour: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
sevenDay: {
total: 0,
bounces: 0,
@@ -100,6 +258,7 @@ export class SecurityService {
bounceRate: 0,
complaintRate: 0,
},
isNewProject: false,
violations: [],
warnings: [],
};
@@ -133,12 +292,20 @@ export class SecurityService {
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
if (status.isNewProject) {
signale.info(
`[SECURITY] Project is under ${SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS} days old — stricter ceilings apply`,
);
}
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
@@ -201,11 +368,17 @@ export class SecurityService {
}
/**
* Get a project's security metrics (for admin/dashboard display)
* Get a project's security metrics (for dashboard display)
* Does NOT expose internal thresholds — only computed health levels
*/
public static async getProjectSecurityMetrics(projectId: string): Promise<{
status: SecurityStatus;
thresholds: typeof SECURITY_THRESHOLDS;
levels: {
bounce7Day: 'healthy' | 'warning' | 'critical';
bounceAllTime: 'healthy' | 'warning' | 'critical';
complaint7Day: 'healthy' | 'warning' | 'critical';
complaintAllTime: 'healthy' | 'warning' | 'critical';
};
isDisabled: boolean;
}> {
const [status, project] = await Promise.all([
@@ -216,13 +389,55 @@ export class SecurityService {
}),
]);
// Strip internal details from the client-facing response:
// - Replace detailed violation/warning messages (they contain exact thresholds)
// - Remove 24-hour data and new project flag (reveals enforcement windows)
const sanitizedStatus: SecurityStatus = {
...status,
twentyFourHour: {total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0},
isNewProject: false,
violations: status.violations.map(() => 'Security threshold exceeded'),
warnings: status.warnings.map(() => 'Approaching security threshold'),
};
return {
status,
thresholds: SECURITY_THRESHOLDS,
status: sanitizedStatus,
levels: {
bounce7Day: this.computeLevel(
status.sevenDay.bounceRate,
SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING,
SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL,
),
bounceAllTime: this.computeLevel(
status.allTime.bounceRate,
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING,
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL,
),
complaint7Day: this.computeLevel(
status.sevenDay.complaintRate,
SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING,
SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL,
),
complaintAllTime: this.computeLevel(
status.allTime.complaintRate,
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING,
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL,
),
},
isDisabled: project?.disabled ?? false,
};
}
private static computeLevel(
value: number,
warningThreshold: number,
criticalThreshold: number,
): 'healthy' | 'warning' | 'critical' {
if (value >= criticalThreshold) return 'critical';
if (value >= warningThreshold) return 'warning';
return 'healthy';
}
/**
* Calculate bounce and complaint rates for a project
*/
@@ -270,10 +485,21 @@ export class SecurityService {
*/
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get 7-day and all-time rates in parallel
const [sevenDay, allTime] = await Promise.all([
// Get project age to determine if stricter new-project thresholds apply
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {createdAt: true},
});
const projectAgeDays = project ? (now.getTime() - project.createdAt.getTime()) / (1000 * 60 * 60 * 24) : Infinity;
const isNewProject = projectAgeDays < SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS;
// Get 24-hour, 7-day and all-time rates in parallel
const [twentyFourHour, sevenDay, allTime] = await Promise.all([
this.calculateRates(projectId, oneDayAgo),
this.calculateRates(projectId, sevenDaysAgo),
this.calculateRates(projectId),
]);
@@ -281,13 +507,63 @@ export class SecurityService {
const violations: string[] = [];
const warnings: string[] = [];
// === Absolute count ceiling checks (new projects only, rate-independent) ===
// Catches new accounts blasting emails before their bounce rate catches up.
// Established projects skip these — high absolute counts at high volume
// (e.g. 100 bounces out of 10K) don't indicate abuse; rate checks handle them.
if (isNewProject) {
// 24-hour bounce ceilings
if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL) {
violations.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL})`,
);
} else if (twentyFourHour.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING) {
warnings.push(
`24-hour bounce count (new project) (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING})`,
);
}
// 7-day bounce ceilings
if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL) {
violations.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL})`,
);
} else if (sevenDay.bounces >= SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING) {
warnings.push(
`7-day bounce count (new project) (${sevenDay.bounces} bounces) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING})`,
);
}
// 24-hour complaint ceilings
if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL) {
violations.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL})`,
);
} else if (twentyFourHour.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING) {
warnings.push(
`24-hour complaint count (new project) (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING})`,
);
}
// 7-day complaint ceilings
if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL) {
violations.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds critical ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL})`,
);
} else if (sevenDay.complaints >= SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING) {
warnings.push(
`7-day complaint count (new project) (${sevenDay.complaints} complaints) exceeds warning ceiling (${SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING})`,
);
}
}
// === Rate-based checks (existing logic) ===
// Only enforce if minimum emails threshold is met
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
// Check 7-day bounce rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
@@ -307,7 +583,6 @@ export class SecurityService {
// Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
@@ -327,7 +602,6 @@ export class SecurityService {
// Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate - requires BOTH rate AND absolute count
if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
@@ -344,7 +618,6 @@ export class SecurityService {
);
}
// Check all-time complaint rate - requires BOTH rate AND absolute count
if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
@@ -366,8 +639,10 @@ export class SecurityService {
projectId,
isHealthy: violations.length === 0,
shouldDisable: violations.length > 0,
twentyFourHour,
sevenDay,
allTime,
isNewProject,
violations,
warnings,
};
@@ -401,7 +676,7 @@ export class SecurityService {
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true},
data: {disabled: true, disabledReason: 'EMAIL_REPUTATION'},
});
// Log critical security event
@@ -440,7 +715,7 @@ export class SecurityService {
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
);
}
} catch (emailError) {
@@ -450,4 +725,293 @@ export class SecurityService {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
}
/**
* Check email content for phishing or dangerous content using LLM
* Uses sampling to reduce costs - only checks a percentage of emails
* @returns { isPhishing: boolean, confidence: number, shouldDisable: boolean }
*/
public static async checkPhishingContent(
projectId: string,
projectName: string,
fromEmail: string,
subject: string,
body: string,
): Promise<{isPhishing: boolean; confidence: number; shouldDisable: boolean}> {
// Default safe response
const safeResponse = {isPhishing: false, confidence: 0, shouldDisable: false};
try {
// Check if phishing detection is enabled
if (!PHISHING_DETECTION_ENABLED) {
return safeResponse;
}
// Sample-based checking - only check a percentage of emails
if (Math.random() > PHISHING_DETECTION_SAMPLE_RATE) {
return safeResponse;
}
signale.info(`[PHISHING] Checking email for project ${projectId} (sampled)`);
// Strip HTML tags for content analysis (basic HTML removal)
const strippedBody = body
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Extract URLs from the original HTML body for domain analysis
const urlMatches = body.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
// Extract sender domain for context
const senderDomain = (fromEmail.split('@')[1] ?? fromEmail).toLowerCase();
// Check whether this domain is verified by the project. A verified
// domain means the sender proved DNS/DKIM control — strong evidence of
// legitimacy, especially for institutional TLDs like .gov, .edu, .mil.
const verifiedDomain = await prisma.domain.findFirst({
where: {projectId, domain: senderDomain, verified: true},
select: {domain: true},
});
const isDomainVerified = verifiedDomain !== null;
// Institutional TLDs that imply a vetted, real-world entity behind the
// domain (government, military, accredited education). When combined
// with DKIM verification these effectively cannot be phishing senders.
const institutionalTldPattern =
/\.(gov|mil|edu)(\.[a-z]{2,})?$|\.gc\.ca$|\.gouv\.fr$|\.gov\.uk$|\.ac\.[a-z]{2,}$/i;
const isInstitutionalDomain = institutionalTldPattern.test(senderDomain);
// Skip the LLM check entirely when the sender is a verified institutional
// domain (e.g. a .gov customer). These TLDs are gated by registries that
// verify the real-world entity, and DKIM verification proves the project
// controls the domain — together they make phishing effectively
// impossible from this sender. Avoids paying for an LLM call that
// sometimes false-positives on official government communications.
if (isDomainVerified && isInstitutionalDomain) {
signale.info(
`[PHISHING] Skipping check for project ${projectId} — verified institutional domain (${senderDomain})`,
);
return safeResponse;
}
// Call OpenRouter API
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://useplunk.com',
'X-Title': 'Plunk Email Platform',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content: `You are a security expert analyzing emails for phishing, scams, and dangerous content. Analyze the email subject and body and respond with a JSON object in this exact format:
{
"is_phishing": true/false,
"confidence": 0-100,
"reason": "brief explanation"
}
Criteria for phishing/dangerous content:
- Credential theft attempts (fake login pages, password requests)
- Financial scams (fake invoices, lottery scams, advance-fee fraud)
- Malicious links or attachments references
- Impersonation of banks, government, or well-known companies
- Urgent threats or fear-based manipulation
- Suspicious cryptocurrency or investment schemes
- Requests for sensitive personal information
IMPORTANT - Use sender and project context when evaluating:
- The sender project name and domain are provided, along with whether the domain has been verified (DKIM/DNS) by this project.
- A VERIFIED sender domain means the sender proved ownership of the domain via DNS records. This is strong evidence of legitimacy.
- If the verified sender domain is an institutional domain (e.g. .gov, .gov.uk, .gouv.fr, .gc.ca, .mil, .edu, .ac.*), treat the email as legitimate institutional communication. Government, military, and accredited education domains cannot be obtained by phishers — do NOT flag these as impersonation of government/banks/etc. just because the content mentions official topics, taxes, benefits, court notices, etc.
- Impersonation rules only apply when the sender domain does NOT match the brand being referenced. A verified bank domain sending a banking email is not impersonating itself.
- Links to the sender's own domain(s) are expected and NOT suspicious.
- URLs that match or are clearly related to the project name or sender domain add credibility.
- Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand.
- Lack of recognizable brand does NOT make an email phishing — many legitimate businesses are not famous.
Be strict but fair. Marketing emails and legitimate transactional emails are NOT phishing.
Only flag content that is CLEARLY attempting to deceive or harm recipients.
Set confidence to 100 only if you are absolutely certain it's phishing.`,
},
{
role: 'user',
content: `Sender project name: ${projectName}
Sender domain: ${senderDomain}
Sender domain verified (DKIM/DNS confirmed by project): ${isDomainVerified ? 'yes' : 'no'}
Sender domain is an institutional TLD (gov/mil/edu/ac/etc.): ${isInstitutionalDomain ? 'yes' : 'no'}
${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''}
Subject: ${subject}
Body:
${strippedBody.substring(0, 2000)}`,
},
],
temperature: 0.1,
max_tokens: 200,
response_format: {type: 'json_object'},
}),
});
if (!response.ok) {
signale.error(`[PHISHING] OpenRouter API error: ${response.status} ${response.statusText}`);
return safeResponse;
}
const data = (await response.json()) as {
choices?: Array<{message?: {content?: string}}>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
signale.error('[PHISHING] No content in OpenRouter response');
return safeResponse;
}
// Parse the JSON response (response_format ensures clean JSON)
const result = JSON.parse(content) as {
is_phishing: boolean;
confidence: number;
reason: string;
};
const isPhishing = result.is_phishing === true;
const confidence = Math.min(100, Math.max(0, result.confidence || 0));
if (isPhishing) {
signale.warn(
`[PHISHING] Detected phishing content for project ${projectId} - Confidence: ${confidence}% - Reason: ${result.reason}`,
);
// Track this detection for cumulative analysis
await trackPhishingDetection(projectId, confidence, subject);
// Get count of recent detections
const recentCount = await getRecentPhishingCount(projectId);
signale.info(
`[PHISHING] Project ${projectId} has ${recentCount} phishing detection(s) in the last ${PHISHING_CUMULATIVE_WINDOW_MS / 1000 / 60} minutes`,
);
} else {
signale.success(`[PHISHING] Passed phishing check for project: ${projectId}`);
}
// Determine if project should be disabled
// Disable if EITHER:
// 1. Single detection with high confidence (>= threshold)
// 2. Multiple detections within time window (>= cumulative threshold)
const meetsConfidenceThreshold = isPhishing && confidence >= PHISHING_CONFIDENCE_THRESHOLD;
const recentCount = await getRecentPhishingCount(projectId);
const meetsCumulativeThreshold = isPhishing && recentCount >= PHISHING_CUMULATIVE_THRESHOLD;
const shouldDisable = meetsConfidenceThreshold || meetsCumulativeThreshold;
if (shouldDisable) {
if (meetsConfidenceThreshold) {
signale.error(
`[PHISHING] High confidence phishing detected (${confidence}% >= ${PHISHING_CONFIDENCE_THRESHOLD}%) - will disable project ${projectId}`,
);
}
if (meetsCumulativeThreshold) {
signale.error(
`[PHISHING] Cumulative threshold reached (${recentCount} >= ${PHISHING_CUMULATIVE_THRESHOLD} detections) - will disable project ${projectId}`,
);
}
// Clear history after disabling
await clearPhishingHistory(projectId);
}
return {
isPhishing,
confidence,
shouldDisable,
};
} catch (error) {
// Log error but don't throw - we don't want phishing checks to break email sending
// Better to let a phishing email through than to break legitimate emails
signale.error(`[PHISHING] Failed to check content for project ${projectId}:`, error);
return safeResponse;
}
}
/**
* Disable a project due to phishing detection
*/
public static async disableProjectForPhishing(
projectId: string,
subject: string,
confidence: number,
reason?: string,
): Promise<void> {
try {
// Check if already disabled to avoid duplicate logs
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {id: true, disabled: true, name: true},
});
if (!project) {
signale.error(`[PHISHING] Project ${projectId} not found`);
return;
}
if (project.disabled) {
signale.warn(`[PHISHING] Project ${projectId} (${project.name}) already disabled`);
return;
}
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true, disabledReason: 'PHISHING_DETECTED'},
});
const violation = `A policy violation was detected. Please contact support for more details.`;
// Log critical security event
signale.error(
`[PHISHING] Project ${projectId} (${project.name}) has been automatically disabled due to phishing detection:`,
violation,
);
// Cancel all pending jobs for this project
try {
await QueueService.cancelAllProjectJobs(projectId);
signale.info(`[PHISHING] Cancelled all pending jobs for project ${projectId}`);
} catch (error) {
signale.error(`[PHISHING] Failed to cancel pending jobs for project ${projectId}:`, error);
}
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, [violation]);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: [violation],
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
);
}
} catch (emailError) {
signale.error(`[PHISHING] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[PHISHING] Failed to disable project ${projectId}:`, error);
}
}
}
+185 -20
View File
@@ -91,7 +91,7 @@ export class SegmentService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
const [contacts, total] = await Promise.all([
prisma.contact.findMany({
@@ -137,7 +137,7 @@ export class SegmentService {
this.validateCondition(data.condition);
// Compute initial member count
const where = this.buildWhereClause(projectId, data.condition);
const where = await this.buildWhereClause(projectId, data.condition);
memberCount = await prisma.contact.count({where});
conditionJson = toPrismaJson(data.condition);
}
@@ -192,10 +192,14 @@ export class SegmentService {
if (data.condition !== undefined && existing.type !== 'STATIC') {
// Validate condition if provided (only for DYNAMIC segments)
this.validateCondition(data.condition);
if (this.getReferencedSegmentIds(data.condition).has(segmentId)) {
throw new HttpException(400, 'A segment cannot reference itself');
}
updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition);
const where = await this.buildWhereClause(projectId, data.condition);
updateData.memberCount = await prisma.contact.count({where});
}
if (data.trackMembership !== undefined) {
@@ -267,7 +271,7 @@ export class SegmentService {
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
@@ -305,7 +309,7 @@ export class SegmentService {
});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
@@ -328,7 +332,9 @@ export class SegmentService {
projectId: string,
segmentId: string,
emails: string[],
): Promise<{added: number; notFound: string[]}> {
createMissing = false,
subscribed = true,
): Promise<{added: number; created: number; notFound: string[]}> {
const segment = await this.get(projectId, segmentId);
if (segment.type !== 'STATIC') {
@@ -336,7 +342,7 @@ export class SegmentService {
}
// Look up contacts by email (case-insensitive)
const contacts = await prisma.contact.findMany({
let contacts = await prisma.contact.findMany({
where: {
projectId,
email: {in: emails, mode: 'insensitive'},
@@ -344,7 +350,24 @@ export class SegmentService {
select: {id: true, email: true},
});
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
let foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
const missing = emails.filter(e => !foundEmails.has(e.toLowerCase()));
let created = 0;
if (createMissing && missing.length > 0) {
await prisma.contact.createMany({
data: missing.map(email => ({projectId, email, subscribed})),
skipDuplicates: true,
});
created = missing.length;
// Re-fetch to include newly created contacts
contacts = await prisma.contact.findMany({
where: {projectId, email: {in: emails, mode: 'insensitive'}},
select: {id: true, email: true},
});
foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
}
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
if (contacts.length > 0) {
@@ -377,7 +400,7 @@ export class SegmentService {
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
}
return {added: contacts.length, notFound};
return {added: contacts.length, created, notFound};
}
/**
@@ -441,7 +464,7 @@ export class SegmentService {
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const where = await this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues
const BATCH_SIZE = 1000;
@@ -610,9 +633,57 @@ export class SegmentService {
/**
* Build a single filter condition
*/
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
public static async buildFilterCondition(
filter: SegmentFilter,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const {field, operator, value, unit} = filter;
// Handle segment membership filters (e.g., "segment.<segmentId>")
if (field.startsWith('segment.')) {
const segmentId = field.substring(8);
if (visitedSegments.has(segmentId)) {
throw new HttpException(400, 'Circular segment reference detected');
}
const referencedSegment = await prisma.segment.findUnique({
where: {id: segmentId},
});
if (!referencedSegment) {
throw new HttpException(400, `Referenced segment not found: ${segmentId}`);
}
let memberIds: string[];
if (referencedSegment.type === 'STATIC' || referencedSegment.trackMembership) {
// Use the membership table for static or tracked segments
const memberships = await prisma.segmentMembership.findMany({
where: {segmentId, exitedAt: null},
select: {contactId: true},
});
memberIds = memberships.map(m => m.contactId);
} else {
// For untracked dynamic segments, evaluate the condition recursively
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
const nextVisited = new Set(visitedSegments).add(segmentId);
const nestedWhere = await this.buildConditionClause(nestedCondition, nextVisited);
if (operator === 'memberOfSegment') {
return nestedWhere;
}
return {NOT: nestedWhere};
}
if (operator === 'memberOfSegment') {
return {id: {in: memberIds}};
}
return {id: {notIn: memberIds}};
}
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
if (field.startsWith('event.')) {
const eventName = field.substring(6); // Remove "event." prefix
@@ -645,6 +716,26 @@ export class SegmentService {
}
}
/**
* Collect all segment IDs directly referenced in a condition (non-recursive DB lookup)
*/
private static getReferencedSegmentIds(condition: FilterCondition): Set<string> {
const ids = new Set<string>();
for (const group of condition.groups) {
for (const filter of group.filters) {
if (filter.field.startsWith('segment.')) {
ids.add(filter.field.substring(8));
}
}
if (group.conditions) {
for (const id of this.getReferencedSegmentIds(group.conditions)) {
ids.add(id);
}
}
}
return ids;
}
/**
* Validate segment condition (recursive)
*/
@@ -669,8 +760,13 @@ export class SegmentService {
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
public static async buildConditionClause(
condition: FilterCondition,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const groupClauses = await Promise.all(
condition.groups.map(group => this.buildGroupClause(group, visitedSegments)),
);
if (condition.logic === 'AND') {
return {AND: groupClauses};
@@ -739,12 +835,23 @@ export class SegmentService {
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
'notTriggeredWithin',
'memberOfSegment',
'notMemberOfSegment',
];
if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
}
// Segment membership operators use the segmentId encoded in the field name, no separate value needed
if (filter.operator === 'memberOfSegment' || filter.operator === 'notMemberOfSegment') {
if (!filter.field.startsWith('segment.')) {
throw new HttpException(400, 'memberOfSegment/notMemberOfSegment operators require a segment field (segment.<id>)');
}
return;
}
// Validate that operators that need a value have one
const operatorsNeedingValue = [
'equals',
@@ -759,6 +866,7 @@ export class SegmentService {
'olderThan',
'triggeredWithin',
'triggeredOlderThan',
'notTriggeredWithin',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
@@ -766,7 +874,7 @@ export class SegmentService {
}
// Validate unit for time-based operators
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
}
}
@@ -774,27 +882,36 @@ export class SegmentService {
/**
* Build Prisma where clause from filter condition (entry point)
*/
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
private static async buildWhereClause(
projectId: string,
condition: FilterCondition,
): Promise<Prisma.ContactWhereInput> {
return {
projectId,
...this.buildConditionClause(condition),
...(await this.buildConditionClause(condition)),
};
}
/**
* Build Prisma clause from filter group (recursive)
*/
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
private static async buildGroupClause(
group: FilterGroup,
visitedSegments = new Set<string>(),
): Promise<Prisma.ContactWhereInput> {
const clauses: Prisma.ContactWhereInput[] = [];
// Add filter conditions from this group
if (group.filters.length > 0) {
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
const filterClauses = await Promise.all(
group.filters.map(filter => this.buildFilterCondition(filter, visitedSegments)),
);
clauses.push(...filterClauses);
}
// Add nested condition if present
if (group.conditions) {
clauses.push(this.buildConditionClause(group.conditions));
clauses.push(await this.buildConditionClause(group.conditions, visitedSegments));
}
// All conditions within a group are combined with AND
@@ -1147,6 +1264,28 @@ export class SegmentService {
},
};
case 'notTriggeredWithin': {
// Contact has not triggered this event within the timeframe (includes never-triggered contacts)
if (!unit) {
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
events: {
none: {
name: eventName,
createdAt: {
gte: since,
},
},
},
};
}
default:
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
}
@@ -1162,13 +1301,18 @@ export class SegmentService {
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
// Map activity names to Email model fields
// Map activity names to Email model fields (accept both verb and past-tense forms)
const fieldMap: Record<string, string> = {
open: 'openedAt',
opened: 'openedAt',
click: 'clickedAt',
clicked: 'clickedAt',
bounce: 'bouncedAt',
bounced: 'bouncedAt',
complaint: 'complainedAt',
complained: 'complainedAt',
sent: 'sentAt',
delivery: 'deliveredAt',
delivered: 'deliveredAt',
};
@@ -1259,6 +1403,27 @@ export class SegmentService {
},
};
case 'notTriggeredWithin': {
// Contact has not had this email activity within the timeframe (includes contacts with no activity)
if (!unit) {
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
emails: {
none: {
[field]: {
gte: since,
},
},
},
};
}
default:
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
}
@@ -908,7 +908,14 @@ export class WorkflowExecutionService {
}
/**
* WEBHOOK step - Call an external webhook
* WEBHOOK step - Call an external webhook.
*
* Renders `{{vars}}` in `url`, header values, and `body`. The variable
* scope is a superset of the SEND_EMAIL scope: id, email, contact data,
* execution context, and subscribe/unsubscribe/manage URLs — plus a
* webhook-only `event` namespace exposing the trigger event payload.
* `method` is intentionally NOT rendered — it must remain a literal
* HTTP verb.
*/
private static async executeWebhook(
_step: WorkflowStep,
@@ -924,9 +931,37 @@ export class WorkflowExecutionService {
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)
? (contact.data as Record<string, unknown>)
: {};
const executionContext =
execution.context && typeof execution.context === 'object' && !Array.isArray(execution.context)
? (execution.context as Record<string, unknown>)
: {};
const context = execution.context || {};
const payload = body || {
// Render scope: SEND_EMAIL's scope (id, email, contact data, execution
// context, subscribe/unsubscribe/manage URLs) plus a webhook-only
// `event` namespace carrying the trigger event payload. `method` is
// intentionally NOT rendered — it must remain a literal HTTP verb.
const variables = {
id: contact.id,
email: contact.email,
...contactData,
...executionContext,
data: contactData,
event: context,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
const renderedUrl = this.renderTemplate(url, variables);
const renderedHeaders = headers
? Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key, this.renderTemplate(value, variables)]),
)
: undefined;
const renderedBody = body ? this.renderJsonTemplate(body, variables) : undefined;
const payload = renderedBody || {
contact: {
email: contact.email,
subscribed: contact.subscribed,
@@ -944,11 +979,11 @@ export class WorkflowExecutionService {
};
// Make HTTP request
const response = await WorkflowExecutionService.safeFetch(url, {
const response = await WorkflowExecutionService.safeFetch(renderedUrl, {
method,
headers: {
'Content-Type': 'application/json',
...headers,
...renderedHeaders,
},
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
});
@@ -962,7 +997,7 @@ export class WorkflowExecutionService {
}
return {
url,
url: renderedUrl,
method,
statusCode: response.status,
success: response.ok,
@@ -970,6 +1005,28 @@ export class WorkflowExecutionService {
};
}
/**
* Helper: Recursively render template variables in any JSON-shaped value.
* Strings are rendered, arrays/objects are walked, and non-string scalars
* (numbers, booleans, null) are returned untouched.
*/
private static renderJsonTemplate(value: unknown, variables: Record<string, unknown>): unknown {
if (typeof value === 'string') {
return this.renderTemplate(value, variables);
}
if (Array.isArray(value)) {
return value.map(item => this.renderJsonTemplate(item, variables));
}
if (value !== null && typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
result[key] = this.renderJsonTemplate(child, variables);
}
return result;
}
return value;
}
/**
* UPDATE_CONTACT step - Update contact data
*/
@@ -979,7 +1036,7 @@ export class WorkflowExecutionService {
_stepExecution: WorkflowStepExecution,
config: StepConfig,
): Promise<StepResult> {
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
const contact = execution.contact;
const currentData =
@@ -987,24 +1044,43 @@ export class WorkflowExecutionService {
? (contact.data as Record<string, unknown>)
: {};
// Merge updates with current data
const newData = {
...currentData,
...updates,
};
const hasDataUpdates = updates && Object.keys(updates).length > 0;
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
// Update contact in database
await prisma.contact.update({
where: {id: contact.id},
data: {
data: newData ? toPrismaJson(newData) : undefined,
},
});
const desiredSubscribed =
subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined;
const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed;
const updateData: Prisma.ContactUpdateInput = {};
if (hasDataUpdates) {
updateData.data = toPrismaJson(newData);
}
if (subscriptionChanging) {
updateData.subscribed = desiredSubscribed;
}
if (Object.keys(updateData).length > 0) {
await prisma.contact.update({
where: {id: contact.id},
data: updateData,
});
}
if (subscriptionChanging) {
const {EventService} = await import('./EventService.js');
await EventService.trackEvent(
execution.workflow.projectId,
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
contact.id,
);
}
return {
updated: true,
updated: hasDataUpdates || subscriptionChanging,
updates,
newData,
subscriptionAction,
subscribed: desiredSubscribed ?? contact.subscribed,
};
}
+126
View File
@@ -310,6 +310,72 @@ export class WorkflowService {
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
}
/**
* Duplicate a workflow including all steps and transitions.
* The duplicate always starts disabled to prevent accidental triggering.
* Runtime execution state is intentionally not copied.
*/
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
const source = await this.get(projectId, workflowId);
const transitions = await prisma.workflowTransition.findMany({
where: {fromStep: {workflowId}},
});
return prisma.$transaction(async tx => {
const newWorkflow = await tx.workflow.create({
data: {
projectId,
name: `${source.name} (Copy)`,
description: source.description,
triggerType: source.triggerType,
triggerConfig:
source.triggerConfig === null
? Prisma.JsonNull
: (source.triggerConfig as Prisma.InputJsonValue),
enabled: false,
allowReentry: source.allowReentry,
},
});
const stepIdMap = new Map<string, string>();
for (const step of source.steps) {
const created = await tx.workflowStep.create({
data: {
workflowId: newWorkflow.id,
type: step.type,
name: step.name,
position: step.position as Prisma.InputJsonValue,
config: step.config as Prisma.InputJsonValue,
templateId: step.templateId,
},
});
stepIdMap.set(step.id, created.id);
}
for (const transition of transitions) {
const fromStepId = stepIdMap.get(transition.fromStepId);
const toStepId = stepIdMap.get(transition.toStepId);
if (!fromStepId || !toStepId) continue;
await tx.workflowTransition.create({
data: {
fromStepId,
toStepId,
condition:
transition.condition === null
? Prisma.JsonNull
: (transition.condition as Prisma.InputJsonValue),
priority: transition.priority,
},
});
}
return newWorkflow;
});
}
/**
* Add a step to a workflow
*/
@@ -576,6 +642,66 @@ export class WorkflowService {
});
}
/**
* Splice a step out of the flow: re-wire its parent(s) directly to its child,
* then delete only the step itself. Not allowed for CONDITION or TRIGGER steps.
*/
public static async spliceStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
include: {
outgoingTransitions: true,
incomingTransitions: true,
},
});
if (step?.workflowId !== workflowId) {
throw new HttpException(404, 'Workflow step not found');
}
if (step.type === 'TRIGGER') {
throw new HttpException(400, 'Cannot remove the trigger step.');
}
if (step.type === 'CONDITION') {
throw new HttpException(400, 'Cannot splice a condition step out of the flow.');
}
// Check for active executions on this step
const executionsOnStep = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: stepId,
status: {in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING]},
},
});
if (executionsOnStep > 0) {
throw new HttpException(
409,
`Cannot remove step "${step.name}" while ${executionsOnStep} execution(s) are currently on it. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
// Re-wire: for each incoming transition, point it to our child (if we have one)
const child = step.outgoingTransitions[0];
if (child) {
for (const incoming of step.incomingTransitions) {
await prisma.workflowTransition.update({
where: {id: incoming.id},
data: {toStepId: child.toStepId},
});
}
}
// Delete the step — cascades and removes its own transitions
await prisma.workflowStep.delete({where: {id: stepId}});
}
/**
* Create a transition between two steps
*/
@@ -336,19 +336,75 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
});
});
it('should handle null values in data', async () => {
it('should delete keys when null value is passed', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: null,
middleName: null, // null should delete/not store the key
lastName: 'Doe',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('middleName', null);
expect(contact.data).not.toHaveProperty('middleName'); // Key should not exist
expect(contact.data).toHaveProperty('lastName', 'Doe');
});
it('should remove existing fields when null is passed', async () => {
const email = 'test@example.com';
// Create contact with data
await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: 'Michael',
lastName: 'Doe',
});
// Update with null to remove middleName
const updated = await ContactService.upsert(projectId, email, {
middleName: null, // Should delete this field
});
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
expect(updated.data).not.toHaveProperty('middleName'); // Removed
expect(updated.data).toHaveProperty('lastName', 'Doe'); // Preserved
});
it('should filter out empty string values from data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: '', // Empty string should be filtered out
lastName: 'Doe',
company: '', // Empty string should be filtered out
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('lastName', 'Doe');
expect(contact.data).not.toHaveProperty('middleName');
expect(contact.data).not.toHaveProperty('company');
});
it('should not overwrite existing data with empty strings', async () => {
const email = 'test@example.com';
// Create contact with data
await ContactService.upsert(projectId, email, {
firstName: 'John',
company: 'Acme Inc',
});
// Update with empty string - should preserve existing values
const updated = await ContactService.upsert(projectId, email, {
firstName: '', // Should be filtered out, preserving "John"
lastName: 'Doe',
});
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
expect(updated.data).toHaveProperty('company', 'Acme Inc'); // Preserved
expect(updated.data).toHaveProperty('lastName', 'Doe'); // New value
});
});
describe('Contact CRUD Operations', () => {
@@ -787,8 +787,8 @@ describe('EmailService', () => {
}
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
it('should validate attachment size limit (10MB total default)', () => {
// Exceeds ~13.4M base64 chars for the default 10MB limit
const largeContent = 'A'.repeat(14000000);
const result = ActionSchemas.send.safeParse({
@@ -0,0 +1,246 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailStatus, EmailSourceType} from '@plunk/db';
import {SecurityService} from '../SecurityService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
AUTO_PROJECT_DISABLE: true,
};
});
// Mock NtfyService to prevent actual notifications
vi.mock('../NtfyService.js', () => ({
NtfyService: {
notifySecurityWarning: vi.fn(),
notifyProjectDisabledForSecurity: vi.fn(),
},
}));
// Mock email sending for project disabled notifications
vi.mock('@plunk/email', () => ({
ProjectDisabledEmail: vi.fn(),
sendPlatformEmail: vi.fn(),
}));
describe('SecurityService', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
// Clear redis cache
await redis.flushdb();
});
/**
* Helper to create N emails, some of which are bounced
*/
async function createEmails(count: number, opts?: {bouncedCount?: number; complainedCount?: number; createdAt?: Date}) {
const bouncedCount = opts?.bouncedCount ?? 0;
const complainedCount = opts?.complainedCount ?? 0;
const createdAt = opts?.createdAt ?? new Date();
const data = Array.from({length: count}, (_, i) => ({
projectId,
contactId,
subject: `Test ${i}`,
body: '<p>test</p>',
from: 'test@example.com',
status: EmailStatus.SENT,
sourceType: EmailSourceType.TRANSACTIONAL,
sentAt: createdAt,
createdAt,
bouncedAt: i < bouncedCount ? createdAt : null,
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
}));
await prisma.email.createMany({data});
}
describe('Rate-based checks (existing behavior)', () => {
it('should report healthy when bounce rate is below warning', async () => {
// 200 emails, 5 bounces = 2.5% (below 5% warning)
await createEmails(200, {bouncedCount: 5});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.shouldDisable).toBe(false);
expect(status.violations).toHaveLength(0);
expect(status.warnings).toHaveLength(0);
});
it('should trigger warning when 7-day bounce rate exceeds warning threshold', async () => {
// 100 emails, 6 bounces = 6% (above 5% warning, below 10% critical)
await createEmails(100, {bouncedCount: 6});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.warnings.length).toBeGreaterThan(0);
});
it('should trigger violation when 7-day bounce rate exceeds critical threshold', async () => {
// 100 emails, 11 bounces = 11% (above 10% critical)
await createEmails(100, {bouncedCount: 11});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(false);
expect(status.shouldDisable).toBe(true);
expect(status.violations.length).toBeGreaterThan(0);
});
it('should not enforce rate checks when below minimum volume', async () => {
// 50 emails (below 100 minimum), 10 bounces = 20% (would exceed critical)
await createEmails(50, {bouncedCount: 10});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.violations).toHaveLength(0);
});
});
describe('Established projects skip absolute ceilings', () => {
// Age the project past the new-project window
beforeEach(async () => {
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
});
it('should NOT trigger on high absolute bounce count when rate is healthy', async () => {
// 20,000 emails, 200 bounces = 1% rate (well below rate threshold)
// Established projects rely solely on rates — high absolute counts at
// high volume don't indicate abuse.
await createEmails(20000, {bouncedCount: 200});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.shouldDisable).toBe(false);
expect(status.violations).toHaveLength(0);
expect(status.warnings).toHaveLength(0);
});
it('should NOT trigger on high absolute complaint count when rate is healthy', async () => {
// 100,000 emails, 30 complaints = 0.03% (at warning floor, below critical 0.15%)
// Old absolute ceiling (25 complaints in 7d critical) would have tripped.
await createEmails(100000, {complainedCount: 30});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(false);
});
});
describe('New project stricter thresholds', () => {
it('should apply stricter ceilings for projects under 30 days old', async () => {
// Default project is created "now", so it's a new project
// 10,000 emails, 26 bounces (above 25 new project 24h critical ceiling)
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(true);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
});
it('should NOT apply absolute ceilings for projects over 30 days old', async () => {
// Age the project to 31 days
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
// 10,000 emails, 26 bounces — would trip new-project ceiling, but
// established projects skip ceilings entirely (rate is 0.26%, healthy).
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(false);
expect(status.warnings.some(w => w.includes('bounce count'))).toBe(false);
expect(status.violations.some(v => v.includes('bounce count'))).toBe(false);
});
it('should catch new project blasting emails with delayed bounces', async () => {
// Simulate the spammer scenario: new project sends 20K emails,
// only 30 bounces have come back so far (rate is tiny: 0.15%)
await createEmails(20000, {bouncedCount: 30});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(true);
expect(status.shouldDisable).toBe(true);
// 30 > 25 new project 24h critical ceiling
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
});
});
describe('checkAndEnforceSecurityLimits', () => {
it('should disable project when critical thresholds are exceeded', async () => {
// New project, 20K emails with 30 bounces — exceeds new project 24h critical ceiling
await createEmails(20000, {bouncedCount: 30});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
expect(project?.disabled).toBe(true);
});
it('should NOT disable project when only warnings exist', async () => {
// Established project, 200 emails, 12 bounces = 6% (above 5% warning, below 10% critical)
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
await createEmails(200, {bouncedCount: 12});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
expect(project?.disabled).toBe(false);
});
});
describe('getProjectSecurityMetrics (client-facing)', () => {
it('should NOT expose internal thresholds or detailed violation messages', async () => {
// Create a violation scenario
await createEmails(100, {bouncedCount: 15});
const metrics = await SecurityService.getProjectSecurityMetrics(projectId);
// Should have levels, not thresholds
expect(metrics.levels).toBeDefined();
expect((metrics as Record<string, unknown>).thresholds).toBeUndefined();
// Violation messages should be generic
if (metrics.status.violations.length > 0) {
for (const v of metrics.status.violations) {
expect(v).toBe('Security threshold exceeded');
expect(v).not.toMatch(/\d+%/); // No percentages
expect(v).not.toMatch(/\d+ minimum/); // No absolute numbers
}
}
// 24-hour data should be zeroed out
expect(metrics.status.twentyFourHour.total).toBe(0);
expect(metrics.status.twentyFourHour.bounces).toBe(0);
// New project flag should be hidden
expect(metrics.status.isNewProject).toBe(false);
});
});
});
@@ -0,0 +1,229 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Tests for WEBHOOK step config templating.
*
* `executeWebhook` is a private static method but is invokable at runtime
* through a `as any` cast. We mock `safeFetch` (also private) via the
* same mechanism so we can capture the rendered request without making a
* real network call.
*/
describe('WorkflowExecutionService.executeWebhook templating', () => {
let projectId: string;
const prisma = getPrismaClient();
// Capture (url, options) passed to safeFetch
let safeFetchSpy: ReturnType<typeof vi.spyOn>;
let captured: {url: string; options: RequestInit} | null = null;
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
captured = null;
safeFetchSpy = vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.spyOn(WorkflowExecutionService as any, 'safeFetch')
.mockImplementation(async (...args: unknown[]) => {
const [url, options] = args as [string, RequestInit];
captured = {url, options};
return new Response('{"ok":true}', {
status: 200,
headers: {'Content-Type': 'application/json'},
});
});
});
afterEach(() => {
safeFetchSpy.mockRestore();
});
/**
* Helper: build a workflow with a single WEBHOOK step using the given
* config, plus a contact and a RUNNING execution. Returns the args
* shape `executeWebhook` expects.
*/
async function setup(
webhookConfig: Record<string, unknown>,
contactOverrides: {data?: Record<string, unknown>} = {},
executionContext: Record<string, unknown> = {},
) {
const contact = await factories.createContact({
projectId,
data: contactOverrides.data,
});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
const step = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.WEBHOOK,
name: 'Webhook',
position: {x: 0, y: 0},
config: webhookConfig,
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
context: executionContext,
},
include: {contact: true, workflow: true},
});
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: step.id,
status: StepExecutionStatus.RUNNING,
startedAt: new Date(),
},
});
return {step, execution, stepExecution};
}
async function invokeWebhook(
step: unknown,
execution: unknown,
stepExecution: unknown,
config: unknown,
) {
// Call through `as any` because executeWebhook is private at the
// TypeScript level. JS has no actual access control.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config);
}
it('renders {{vars}} in the URL from contact.data', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/api/users/{{userId}}',
method: 'GET',
},
{data: {userId: 'abc-123'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/api/users/abc-123');
});
it('renders {{vars}} in header values', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
headers: {
Authorization: 'Bearer {{apiToken}}',
'X-Static': 'literal',
},
},
{data: {apiToken: 'secret-token-xyz'}},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const headers = captured!.options.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer secret-token-xyz');
expect(headers['X-Static']).toBe('literal');
});
it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hook',
method: 'POST',
body: {
user: {
email: '{{email}}',
name: '{{firstName}}',
},
ref: 'literal-ref',
tags: ['plan:{{plan}}', 'static'],
},
},
{data: {firstName: 'Ada', plan: 'gold'}},
{campaignId: 'camp-9'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.user.email).toBe(execution.contact.email);
expect(body.user.name).toBe('Ada');
expect(body.ref).toBe('literal-ref');
expect(body.tags).toEqual(['plan:gold', 'static']);
});
it('leaves non-string body leaves untouched', async () => {
const {step, execution, stepExecution} = await setup({
url: 'https://example.com/hook',
method: 'POST',
body: {
score: 42,
active: true,
deleted: null,
meta: {
count: 7,
enabled: false,
},
tags: ['{{plan ?? free}}', 100, false],
},
});
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
const body = JSON.parse(captured!.options.body as string);
expect(body.score).toBe(42);
expect(body.active).toBe(true);
expect(body.deleted).toBe(null);
expect(body.meta).toEqual({count: 7, enabled: false});
// String leaf rendered (with default), non-string leaves preserved.
expect(body.tags).toEqual(['free', 100, false]);
});
it('renders {{event.*}} variables from the trigger payload', async () => {
const {step, execution, stepExecution} = await setup(
{
url: 'https://example.com/hooks/{{event.referrer}}',
method: 'POST',
headers: {
'X-Email-Id': '{{event.emailId}}',
},
body: {
referrer: '{{event.referrer}}',
subject: '{{event.subject}}',
},
},
{},
{referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'},
);
await invokeWebhook(step, execution, stepExecution, step.config);
expect(captured).not.toBeNull();
expect(captured!.url).toBe('https://example.com/hooks/newsletter-may');
const headers = captured!.options.headers as Record<string, string>;
expect(headers['X-Email-Id']).toBe('eml_abc123');
const body = JSON.parse(captured!.options.body as string);
expect(body.referrer).toBe('newsletter-may');
expect(body.subject).toBe('Welcome');
});
});
@@ -758,8 +758,20 @@ describe('WorkflowService', () => {
});
const contact = await factories.createContact({projectId});
// Start first execution (still running)
await WorkflowService.startExecution(projectId, workflow.id, contact.id);
// Insert a RUNNING execution directly to avoid racing with the background
// step processor that startExecution kicks off (a trigger-only workflow can
// transition to COMPLETED before the second call observes it as RUNNING).
const triggerStep = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep?.id,
},
});
// Second execution should fail (first still running)
await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow(
+3
View File
@@ -53,6 +53,9 @@ export const Keys = {
stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`;
},
recentCount(projectId: string, minutes: number): string {
return `activity:recent-count:${projectId}:${minutes}`;
},
},
Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string {
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.1.7",
"next": "^16.2.3",
"next-seo": "^6.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="192" height="30" viewBox="0 0 192 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" fill="#C6FE1E"/>
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" stroke="#B3E910" stroke-width="0.6"/>
<mask id="mask0_1720_1740" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="5" y="8" width="20" height="15">
<path d="M24.0038 8H5.99805V23H24.0038V8Z" fill="white"/>
</mask>
<g mask="url(#mask0_1720_1740)">
<path d="M12.9679 11.4606H12.9599C12.4637 11.3182 11.9387 11.603 11.7707 12.0734C11.585 12.579 11.8795 13.1582 12.3997 13.3182C13.6737 13.6798 14.2211 11.8542 12.9679 11.4606Z" fill="#00160D"/>
<path d="M23.8265 14.0425C22.1859 10.4505 16.9523 11.9369 16.4689 10.9961C15.0525 8.77368 12.4084 7.52568 9.54993 8.16888C9.10979 7.99288 7.59411 7.94488 6.6146 8.56088L7.20518 8.82168C7.25 8.84088 7.23719 8.83608 7.30281 8.86008C7.5701 8.96088 7.52368 8.93208 7.33162 9.03768C6.89148 9.29528 6.34251 9.59448 6 10.0441C6.0144 10.0649 6.75384 10.2425 6.75384 10.2425C6.76664 10.2457 6.90429 10.2601 6.87388 10.3209C4.45551 14.1385 8.5336 19.9001 10.5134 23.0009H16.2449C15.3598 21.4169 14.3483 19.2553 14.694 17.7881C14.7564 17.5225 14.8364 17.1849 15.1629 17.1401C15.952 17.0137 17.0083 17.0249 17.7605 16.9401C17.7605 16.9401 17.7643 16.9401 17.7717 16.9401C17.9318 16.9321 21.8498 16.4345 22.7877 18.8761C22.8678 19.1001 23.0518 18.9545 23.1254 18.8073C23.8249 17.4169 24.2762 15.2281 23.8297 14.0441L23.8265 14.0425ZM17.5013 12.8633C17.226 13.3545 17.0387 13.9929 16.9907 14.5497C16.9651 14.9033 17.0019 15.2521 17.0371 15.6057C17.0563 15.8009 17.0579 16.0473 16.9011 16.1753C16.765 16.2905 16.5393 16.3017 16.3121 16.3177C15.1965 16.3129 12.4757 16.3177 11.4273 15.6041L11.4209 15.5993C9.89564 14.6697 9.07938 12.5705 9.98527 10.9401C10.2782 10.3865 10.8175 10.0265 11.4321 9.89048C12.2228 9.70968 13.0935 9.84408 13.7993 10.2137C14.0874 10.3593 14.4315 10.5513 14.694 10.7721C15.2141 11.2265 15.6591 11.6985 16.3409 11.8441C16.6546 11.9337 16.9811 11.9209 17.2948 11.9849C17.8822 12.1257 17.7349 12.4601 17.4997 12.8601L17.5013 12.8633Z" fill="#00160D"/>
</g>
<path d="M37.392 23V7.4H43.296C47.688 7.4 50.952 10.568 50.952 15.176C50.952 19.64 47.904 23 43.632 23H37.392ZM40.056 20.48H43.44C46.176 20.48 48.168 18.272 48.168 15.2C48.168 12.08 46.08 9.848 43.2 9.848H40.056V20.48ZM57.774 23.288C54.246 23.288 51.75 20.672 51.75 17C51.75 13.328 54.246 10.712 57.774 10.712C61.302 10.712 63.822 13.328 63.822 17C63.822 20.672 61.302 23.288 57.774 23.288ZM57.774 20.816C59.766 20.816 61.206 19.232 61.206 17C61.206 14.72 59.766 13.112 57.774 13.112C55.782 13.112 54.366 14.72 54.366 17C54.366 19.232 55.782 20.816 57.774 20.816ZM69.9786 23.288C66.7866 23.288 64.5066 20.672 64.5066 17C64.5066 13.304 66.7626 10.712 69.9546 10.712C71.5866 10.712 73.0026 11.432 73.9626 12.656V6.68H76.4826V20.6L76.5306 23H74.4666L74.0826 21.2C73.1226 22.496 71.6826 23.288 69.9786 23.288ZM70.5546 20.816C72.5466 20.816 73.9866 19.208 73.9866 17C73.9866 14.744 72.5466 13.136 70.5546 13.136C68.5626 13.136 67.1226 14.744 67.1226 17C67.1226 19.208 68.5626 20.816 70.5546 20.816ZM83.8965 23.288C80.3685 23.288 77.8725 20.672 77.8725 17C77.8725 13.328 80.3685 10.712 83.8965 10.712C87.4245 10.712 89.9445 13.328 89.9445 17C89.9445 20.672 87.4245 23.288 83.8965 23.288ZM83.8965 20.816C85.8885 20.816 87.3285 19.232 87.3285 17C87.3285 14.72 85.8885 13.112 83.8965 13.112C81.9045 13.112 80.4885 14.72 80.4885 17C80.4885 19.232 81.9045 20.816 83.8965 20.816ZM95.667 23V7.4H102.603C105.507 7.4 107.571 9.488 107.571 12.416C107.571 15.368 105.435 17.48 102.483 17.48H98.331V23H95.667ZM98.331 15.2H102.315C103.755 15.2 104.787 14.096 104.787 12.536C104.787 10.976 103.755 9.848 102.315 9.848H98.331V15.2ZM111.711 23.288C109.359 23.288 107.775 21.872 107.775 19.784C107.775 18.176 109.023 16.832 110.919 16.472L114.711 15.728C115.071 15.68 115.311 15.416 115.311 15.104C115.311 13.88 114.423 12.992 113.175 12.992C111.879 12.992 110.871 13.904 110.775 15.272L108.183 14.648C108.567 12.344 110.583 10.712 113.055 10.712C115.839 10.712 117.831 12.584 117.831 15.176V20.6L117.879 23H115.887L115.455 21.368C114.711 22.544 113.343 23.288 111.711 23.288ZM110.487 19.448C110.487 20.312 111.183 20.888 112.335 20.888C114.015 20.888 115.311 19.664 115.311 17.864V17.312C115.071 17.36 114.807 17.432 114.543 17.48L111.927 17.96C111.039 18.128 110.487 18.704 110.487 19.448ZM119.784 25.64C120.84 25.568 121.728 24.992 122.16 23.984L122.712 22.616L117.984 11H120.864L123.96 19.688L126.792 11H129.624L124.44 24.728C123.696 26.696 122.376 27.944 120.744 28.088L119.784 25.64ZM130.303 23V11H132.295L132.727 12.944C133.279 11.624 134.647 10.712 136.255 10.712C137.887 10.712 139.327 11.648 139.951 13.016C140.551 11.648 142.015 10.712 143.695 10.712C146.167 10.712 147.943 12.728 147.943 15.536V23H145.423V15.872C145.423 14.336 144.439 13.208 143.071 13.208C141.535 13.208 140.431 14.504 140.431 16.28V23H137.959V15.92C137.959 14.336 136.951 13.208 135.559 13.208C133.975 13.208 132.823 14.528 132.823 16.376V23H130.303ZM155.115 23.288C151.587 23.288 149.115 20.672 149.115 17C149.115 13.328 151.539 10.712 154.947 10.712C158.763 10.712 160.971 13.976 160.635 17.792H151.803C152.115 19.616 153.411 20.84 155.139 20.84C156.507 20.84 157.611 20.072 157.899 18.944L160.467 19.856C159.843 21.896 157.707 23.288 155.115 23.288ZM151.827 15.92H157.995C157.755 14.24 156.507 13.112 154.947 13.112C153.363 13.112 152.163 14.24 151.827 15.92ZM162.027 23V11H164.043L164.427 12.968C165.075 11.624 166.587 10.712 168.315 10.712C170.955 10.712 172.827 12.8 172.827 15.656V23H170.307V16.064C170.307 14.384 169.203 13.184 167.595 13.184C165.843 13.184 164.547 14.552 164.547 16.472V23H162.027ZM180.599 23.024C178.103 23.792 175.175 23.024 175.175 19.976V13.328H173.375V11H174.071C174.815 11 175.295 10.448 175.295 9.608V7.808H177.695V11H180.455V13.328H177.695V19.4C177.695 20.792 179.015 21.176 180.311 20.696L180.599 23.024ZM186.24 23.288C183.288 23.288 181.08 21.68 180.888 19.448L183.768 18.776C183.84 20.072 184.944 21.032 186.408 21.032C187.536 21.032 188.376 20.432 188.376 19.568C188.376 17.096 181.464 18.032 181.464 14.072C181.464 12.08 183.312 10.712 185.928 10.712C188.616 10.712 190.56 12.104 190.752 14.048L188.016 14.672C187.992 13.616 187.104 12.896 185.856 12.896C184.8 12.896 184.08 13.424 184.08 14.192C184.08 16.592 191.088 15.608 191.088 19.664C191.088 21.776 189.072 23.288 186.24 23.288Z" fill="#00160D"/>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="100%" viewBox="0 0 2400 531" enable-background="new 0 0 2400 531" xml:space="preserve">
<path fill="#5A17DD" opacity="1.000000" stroke="none" d=" M177.531342,1.000000 C179.057083,2.016129 179.936691,3.463687 181.198273,3.982726 C196.529205,10.290123 206.246201,22.020750 211.758423,37.067310 C214.062714,43.357273 214.598770,50.496723 214.855927,57.290924 C215.321609,69.593918 214.975113,81.927650 214.610489,94.993073 C214.374802,105.590721 214.496323,115.443848 214.617844,125.296967 C214.731262,134.873642 214.844681,144.450302 214.696503,154.805176 C213.707352,157.641037 212.979782,159.698669 211.949341,161.867874 C185.808609,176.848602 159.744293,191.340149 134.183105,206.670563 C89.674866,233.364563 87.927750,297.038086 131.449005,324.829407 C157.666046,341.570770 185.279068,356.126007 212.344147,371.739380 C212.423538,371.815125 212.480209,372.027130 212.369049,372.418213 C212.169983,374.965424 212.006592,377.121552 212.005508,379.277710 C211.989853,410.547516 212.059357,441.817566 211.942383,473.086914 C211.919479,479.210052 212.244370,485.631836 210.591049,491.394318 C206.096237,507.060730 196.645874,519.126404 181.619370,526.280518 C177.450226,528.265381 173.208450,530.097656 169.000000,532.000000 C126.979103,532.000000 84.958214,532.000000 42.345772,531.676025 C41.124565,530.844910 40.572868,530.118103 39.853806,529.862976 C23.633379,524.108398 11.737153,513.795288 5.170585,497.604126 C4.555796,496.088257 2.426259,495.186737 1.000005,494.000000 C1.000000,341.645782 1.000000,189.291550 1.334632,36.704620 C2.153957,35.664326 2.605743,34.834736 3.128503,34.052578 C7.677545,27.246292 11.359032,19.558378 17.063332,13.917211 C22.027281,9.008199 29.292238,6.433895 35.493198,2.748482 C35.874008,2.522156 35.840652,1.598958 36.000000,1.000000 C83.020897,1.000000 130.041794,1.000000 177.531342,1.000000 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2401.000000,108.531357 C2398.361084,112.502449 2395.684814,115.977570 2393.088867,119.511719 C2376.352051,142.298141 2344.292480,145.556335 2323.355225,127.012016 C2308.465820,113.824196 2304.169189,97.286690 2308.368164,78.826729 C2311.976562,62.963165 2329.512207,45.781548 2347.566895,44.760101 C2359.529297,44.083317 2370.719727,44.744938 2380.889160,51.499744 C2388.212158,56.364056 2393.808350,62.843418 2397.873047,70.647614 C2398.621338,72.084328 2399.502930,73.451706 2400.661621,74.925537 C2401.000000,86.020897 2401.000000,97.041786 2401.000000,108.531357 z"/>
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M169.449982,532.000000 C173.208450,530.097656 177.450226,528.265381 181.619370,526.280518 C196.645874,519.126404 206.096237,507.060730 210.591049,491.394318 C212.244370,485.631836 211.919479,479.210052 211.942383,473.086914 C212.059357,441.817566 211.989853,410.547516 212.005508,379.277710 C212.006592,377.121552 212.169983,374.965424 212.549744,372.721863 C214.349487,374.014984 215.857407,375.395538 217.365311,376.776123 C216.627380,376.596680 215.889450,376.417236 214.968765,376.193359 C214.968765,408.622589 215.150024,440.841736 214.894073,473.057465 C214.761459,489.751343 210.865372,505.497742 198.480194,517.622131 C194.147690,521.863403 188.375244,524.644470 183.216888,528.026001 C181.331070,529.262268 179.319336,530.306396 177.682526,531.719116 C175.299988,532.000000 172.599976,532.000000 169.449982,532.000000 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.531342,1.000000 C35.840652,1.598958 35.874008,2.522156 35.493198,2.748482 C29.292238,6.433895 22.027281,9.008199 17.063332,13.917211 C11.359032,19.558378 7.677545,27.246292 3.128503,34.052578 C2.605743,34.834736 2.153957,35.664326 1.334632,36.235962 C1.000000,24.406746 1.000000,12.813491 1.000000,1.000000 C12.353279,1.000000 23.707981,1.000000 35.531342,1.000000 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.000000,532.000000 C23.740093,532.000000 12.480186,532.000000 1.000000,532.000000 C1.000000,520.980103 1.000000,509.958710 1.273971,498.144836 C3.225362,500.256165 4.904092,503.159210 6.579990,506.063904 C12.840562,516.914856 21.889536,524.612366 33.221706,529.786804 C33.990940,530.138062 34.415260,531.244690 35.000000,532.000000 z"/>
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M35.437500,532.000000 C34.415260,531.244690 33.990940,530.138062 33.221706,529.786804 C21.889536,524.612366 12.840562,516.914856 6.579990,506.063904 C4.904092,503.159210 3.225362,500.256165 1.273971,497.676147 C1.000000,496.933319 1.000000,495.866669 1.000005,494.399994 C2.426259,495.186737 4.555796,496.088257 5.170585,497.604126 C11.737153,513.795288 23.633379,524.108398 39.853806,529.862976 C40.572868,530.118103 41.124565,530.844910 41.877113,531.676025 C39.958332,532.000000 37.916668,532.000000 35.437500,532.000000 z"/>
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M217.701797,376.986237 C215.857407,375.395538 214.349487,374.014984 212.660889,372.330750 C212.480209,372.027130 212.423538,371.815125 212.456863,371.226196 C212.517105,301.832153 212.545151,233.027054 212.557709,164.221970 C212.557846,163.400101 212.358459,162.578186 212.252228,161.756302 C212.979782,159.698669 213.707352,157.641037 214.620636,155.265472 C215.344513,159.168213 217.859421,156.155777 218.749405,155.648453 C226.304214,151.342041 233.649414,146.669876 241.170090,142.301239 C255.990524,133.692307 270.865234,125.176292 285.751129,116.680748 C300.302887,108.375900 314.910339,100.168633 329.466583,91.871597 C338.091431,86.955467 346.691559,81.994705 355.254517,76.971642 C368.468292,69.220383 381.515259,61.175720 394.862762,53.663818 C410.999268,44.582294 426.814056,34.545036 443.863586,27.617456 C453.705078,23.618645 465.243103,23.051941 476.905975,25.483866 C488.326538,27.865265 498.757507,31.301670 507.881378,38.191151 C513.293030,42.277500 517.835571,47.514904 522.653503,52.659882 C522.633423,54.161774 522.340271,55.516911 522.882446,56.273506 C530.810730,67.338020 534.825439,79.932739 534.852600,93.238861 C535.088684,209.045868 535.109619,324.853851 534.812317,440.660553 C534.781067,452.832825 531.410339,464.720093 523.805542,474.833038 C520.241638,479.572327 516.591858,484.247070 512.647461,489.038055 C501.482788,497.758301 489.668152,504.786011 475.582489,505.381836 C465.246460,505.819031 454.537109,505.528076 444.535706,503.217438 C436.519775,501.365479 429.139435,496.303467 421.788544,492.118835 C400.427094,479.958344 379.239807,467.492371 357.951324,455.203186 C333.209625,440.920685 308.448273,426.672119 283.665619,412.460846 C270.255615,404.771027 256.805481,397.150024 243.301727,389.626770 C242.091843,388.952698 240.352737,389.228546 238.859421,389.063232 C238.616684,389.002136 238.373947,388.941010 237.917542,388.412506 C236.750076,387.639465 235.796280,387.333801 234.842499,387.028076 C229.241089,383.750854 223.639679,380.473602 217.701797,376.986237 M296.716461,310.239075 C304.943359,323.504425 315.864471,333.778931 329.811554,340.975372 C340.928680,346.711548 352.553436,350.545807 365.039307,350.861145 C373.992004,351.087280 382.719025,349.423523 391.553467,347.250214 C407.133850,343.417419 419.549103,334.649506 430.206177,323.768677 C438.862549,314.930573 445.481720,304.050262 449.011292,291.869385 C449.959442,291.079254 451.318390,290.469421 451.762421,289.457977 C452.481628,287.819763 452.588501,285.915344 452.974182,284.127716 C459.194275,255.296036 451.962433,229.585983 432.394684,208.282761 C406.602570,180.203156 367.770386,170.884338 331.654480,188.451065 C300.026855,203.834702 283.702362,229.894516 282.017212,265.013763 C281.409424,277.680542 284.214142,289.821014 289.155273,301.371735 C290.547760,304.626892 293.721344,307.120148 296.716461,310.239075 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1744.174194,276.125854 C1743.397339,250.037003 1716.894165,228.103333 1693.279297,227.846497 C1678.464111,227.685364 1665.452637,231.062683 1653.901001,240.373459 C1641.084229,250.704025 1634.178711,264.113495 1634.095581,280.405457 C1633.831787,332.062531 1634.002563,383.721802 1633.999146,435.380219 C1633.998535,444.009430 1633.983765,444.016296 1625.421631,444.019470 C1604.758423,444.027161 1584.093994,443.917572 1563.432617,444.113464 C1558.626099,444.159027 1556.942017,442.406403 1556.949707,437.716370 C1557.035278,385.225494 1556.844727,332.733521 1557.119385,280.243896 C1557.196655,265.465637 1559.156006,250.830490 1564.858765,236.920059 C1573.162476,216.665894 1585.719849,199.820221 1603.551514,186.918121 C1617.478394,176.841232 1632.717773,169.587479 1649.459961,166.024292 C1667.382202,162.209900 1685.510498,161.295441 1703.915283,162.719238 C1720.459717,163.999130 1736.135376,167.906235 1750.562622,175.621109 C1760.132935,180.738770 1768.686768,187.785461 1777.553345,194.166153 C1779.232666,195.374664 1780.243896,197.511505 1780.926636,198.395660 C1791.135864,191.360275 1800.034424,184.745819 1809.402466,178.881607 C1824.746704,169.276382 1841.720825,164.639603 1859.721313,162.872437 C1878.759521,161.003418 1897.559570,162.090714 1916.189331,165.925613 C1932.132935,169.207581 1946.731934,175.813446 1960.179443,185.075195 C1974.098267,194.661621 1985.271973,206.717941 1993.764893,221.264038 C2001.136719,233.890305 2005.305786,247.682434 2007.892090,261.974640 C2008.567871,265.708923 2008.931152,269.553040 2008.935791,273.347351 C2008.999023,325.839020 2008.968994,378.330811 2008.993652,430.822571 C2008.994629,432.803070 2009.134277,434.820038 2009.523438,436.756927 C2010.615234,442.191498 2009.403442,443.998505 2003.981934,444.011566 C1981.818970,444.064911 1959.654907,443.918945 1937.493530,444.109344 C1932.375488,444.153351 1931.930420,441.525269 1931.935669,437.517609 C1932.002563,386.192505 1931.280640,334.853943 1932.255737,283.547241 C1932.811768,254.288528 1910.071411,232.334351 1888.143066,228.585251 C1855.772339,223.050873 1827.595581,243.656631 1822.208252,272.253021 C1821.964111,273.548920 1822.057983,274.908508 1821.744141,277.005432 C1821.331299,279.082092 1821.027954,280.391724 1821.026855,281.701599 C1820.988525,331.474060 1820.969482,381.246521 1821.002930,431.018982 C1821.003906,432.622162 1821.579956,434.224945 1821.888062,435.827911 C1822.129761,443.746552 1821.848755,444.027374 1813.989990,444.025299 C1793.012451,444.019684 1772.034912,444.036743 1751.057373,444.017181 C1744.453125,444.011047 1744.104248,443.630005 1744.039062,436.847382 C1744.013428,434.184235 1744.070435,431.520264 1744.308838,428.041931 C1744.685913,425.915863 1744.981689,424.604614 1744.982422,423.293274 C1745.006958,375.850342 1745.012085,328.407440 1744.969727,280.964539 C1744.968384,279.351410 1744.451050,277.738739 1744.174194,276.125854 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M997.272705,222.229736 C981.579956,246.333069 966.124878,270.164185 950.641724,293.976990 C948.382446,297.451782 947.862183,300.378418 950.462341,304.391327 C965.829834,328.109100 980.822998,352.069336 995.963135,375.934570 C1008.594299,395.844666 1021.280762,415.719696 1033.861938,435.661285 C1035.171021,437.736084 1035.930542,440.157471 1036.944946,442.418121 C1034.288818,442.960144 1031.634888,443.960419 1028.976196,443.972900 C1003.812378,444.090881 978.647278,443.964325 953.483704,444.114227 C949.271606,444.139343 946.969055,442.605499 944.811951,438.979584 C933.002319,419.128113 920.879944,399.462677 908.860474,379.736115 C901.511658,367.675049 894.033752,355.690063 886.872803,343.518524 C884.639282,339.722260 882.293396,337.716003 877.502808,337.876617 C865.683594,338.272888 853.836426,338.240082 842.013184,337.916168 C837.057373,337.780365 835.857788,339.551331 835.884644,344.231659 C836.061646,375.061371 835.986694,405.892578 835.980469,436.723297 C835.979004,444.027771 835.955444,444.024841 828.814270,444.025848 C807.982849,444.028748 787.151489,444.039551 766.320068,444.035645 C759.405029,444.034332 759.030701,443.712067 759.028259,436.617920 C759.003723,365.124664 759.001770,293.631439 759.000061,222.138184 C758.998840,167.810226 759.013245,113.482269 759.004761,59.154316 C759.003479,51.066788 758.963196,51.056705 766.879456,51.054558 C787.877686,51.048862 808.877380,51.187645 829.872925,50.951572 C834.715637,50.897114 836.080383,52.406761 836.071045,57.188786 C835.936829,126.182701 835.980042,195.176971 835.979553,264.171143 C835.979492,271.954742 835.976196,271.973450 843.903870,271.988312 C854.902405,272.008881 865.901855,271.923706 876.899048,272.045898 C880.193970,272.082520 882.186279,271.132050 884.099304,268.093018 C900.273743,242.399155 916.729797,216.882706 933.067810,191.291580 C937.070435,185.022079 940.811890,178.580414 944.982178,172.427185 C946.032654,170.877335 948.267334,169.177643 949.969910,169.164948 C975.798828,168.972351 1001.629822,169.045746 1027.460327,169.085663 C1028.082520,169.086624 1028.704102,169.418045 1030.490356,169.927292 C1020.262573,188.101135 1008.265137,204.650833 997.272705,222.229736 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2204.980713,168.953308 C2221.043945,167.723450 2235.611572,171.417221 2248.408691,181.608231 C2262.422852,192.768524 2271.056885,206.990784 2273.989014,224.687134 C2275.840088,235.861588 2275.574219,246.926849 2271.864502,257.679626 C2266.530029,273.142853 2256.308350,284.991730 2243.103271,294.200104 C2227.181641,305.303131 2210.964111,315.983337 2194.834961,326.786987 C2176.219727,339.255676 2157.545654,351.635986 2138.938721,364.116943 C2136.184814,365.964355 2133.499512,367.985107 2131.103760,370.263489 C2130.050293,371.265289 2128.992188,373.410431 2129.392334,374.503479 C2129.902588,375.897949 2131.739502,377.129395 2133.278320,377.731842 C2134.744141,378.305695 2136.562012,378.041351 2138.225098,378.041504 C2163.891357,378.043518 2189.561035,377.755280 2215.222656,378.099121 C2238.070801,378.405243 2258.320312,385.447876 2273.743652,403.282074 C2282.235352,413.101044 2287.425049,424.725464 2288.739502,437.407593 C2290.328369,452.744629 2290.283447,468.249268 2291.014404,483.678101 C2291.190674,487.394714 2289.923584,489.159607 2285.892822,489.130707 C2267.590576,488.999359 2249.286865,489.078583 2230.983643,489.046204 C2224.881592,489.035400 2224.096680,488.253265 2224.054443,482.091431 C2223.998291,473.925415 2224.121826,465.757599 2224.010254,457.592712 C2223.896973,449.294525 2218.450439,444.013275 2210.188721,444.013000 C2177.356201,444.011871 2144.513184,444.532867 2111.694092,443.876923 C2084.124512,443.325928 2064.515137,429.566284 2052.293457,404.840302 C2045.920166,391.946564 2044.336182,378.257721 2046.364868,364.535309 C2048.635010,349.178345 2056.038330,335.666229 2068.413086,326.050751 C2082.422852,315.164703 2097.209473,305.262756 2111.828857,295.180389 C2134.516357,279.533936 2157.348389,264.096436 2180.119873,248.571442 C2182.999268,246.608231 2185.907227,244.685928 2188.759277,242.683517 C2192.981934,239.718597 2193.051514,238.521896 2189.006348,233.956650 C2186.638428,232.871384 2184.622803,232.059738 2182.605957,232.056213 C2141.426758,231.984299 2100.247803,232.004745 2059.068604,232.006760 C2059.047607,212.932755 2059.107666,193.858063 2058.941895,174.785309 C2058.905029,170.534119 2060.306885,168.788834 2064.690674,168.936020 C2071.814453,169.175186 2078.952881,168.985855 2087.021729,168.979980 C2126.965820,168.972794 2165.973389,168.963058 2204.980713,168.953308 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1324.748779,324.688202 C1325.002808,341.574371 1330.428589,356.530090 1342.807983,368.154205 C1360.779785,385.029358 1382.398315,389.132507 1404.756104,380.121887 C1424.917969,371.996246 1438.287231,356.643585 1440.149292,333.570282 C1440.200928,332.929443 1440.546387,332.312317 1441.273193,331.148987 C1442.192749,328.741425 1442.941895,326.869507 1442.944336,324.996613 C1443.008179,275.239044 1443.006592,225.481339 1442.951050,175.723755 C1442.948853,173.800507 1442.302612,171.877975 1441.956543,169.955124 C1443.713989,169.636307 1445.470459,169.045197 1447.228882,169.040070 C1468.894775,168.976868 1490.562500,169.132477 1512.226074,168.895340 C1516.973755,168.843384 1518.081055,170.485931 1518.072388,174.928711 C1517.967285,229.093658 1518.430786,283.263855 1517.764526,337.420654 C1517.596558,351.070282 1513.692871,364.537415 1507.974609,377.318054 C1497.651611,400.390869 1481.522705,418.155945 1460.118652,431.234528 C1445.469849,440.185425 1429.464355,445.989227 1412.473145,447.821259 C1397.478638,449.437988 1382.223877,450.636871 1367.222656,449.728302 C1344.637451,448.360382 1322.903198,442.137604 1303.746826,429.729980 C1278.831665,413.592529 1261.509277,391.529053 1253.387329,362.369171 C1249.724243,349.217041 1248.143799,336.007904 1248.088867,322.576202 C1247.889404,273.744965 1247.997314,224.912491 1247.979248,176.080475 C1247.976685,169.203812 1248.125977,169.004700 1254.911133,169.001877 C1275.577271,168.993271 1296.243408,168.999054 1316.909546,169.004456 C1325.084473,169.006592 1325.084473,169.012848 1324.666870,178.200836 C1324.302979,188.990707 1324.174561,198.985367 1324.046143,208.980026 C1324.038940,237.012619 1324.031982,265.045227 1324.020264,294.014618 C1324.010010,297.109589 1323.947144,299.269409 1324.009399,301.425629 C1324.232910,309.180481 1324.498901,316.934113 1324.748779,324.688202 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1199.972778,168.956024 C1203.770874,168.731750 1207.571411,168.541504 1211.366333,168.272598 C1214.481812,168.051849 1216.882690,169.153198 1216.878052,172.471832 C1216.850830,192.436768 1216.646729,212.401474 1215.937500,232.479248 C1214.740112,232.415009 1214.107422,232.084122 1213.473877,232.082840 C1192.925293,232.041290 1172.376465,231.980743 1151.829224,232.138947 C1150.552002,232.148788 1149.288086,233.913574 1148.018066,234.861877 C1140.789795,237.727463 1137.886230,242.948029 1137.907104,250.794800 C1138.072021,312.592224 1138.000488,374.390259 1137.996826,436.188110 C1137.996338,444.058746 1137.983398,444.035858 1130.363525,444.031433 C1109.375732,444.019287 1088.385986,443.880737 1067.401245,444.133820 C1062.243896,444.196014 1060.914062,442.400757 1060.923218,437.485229 C1061.046265,371.523376 1060.580811,305.557281 1061.192627,239.600922 C1061.443604,212.545868 1073.475708,190.881134 1097.595825,177.444977 C1107.154053,172.120529 1117.863037,169.110855 1129.092651,169.056427 C1133.423340,169.035431 1137.753906,169.004639 1143.021240,168.980804 C1162.629395,168.974197 1181.301147,168.965103 1199.972778,168.956024 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2380.845947,169.001526 C2381.656006,169.001328 2381.989258,169.004852 2382.322510,169.000549 C2393.011719,168.862213 2393.027100,168.862091 2393.027344,180.050995 C2393.030029,265.520142 2393.027588,350.989288 2393.020996,436.458435 C2393.020508,444.034637 2393.002197,444.032440 2385.673828,444.031891 C2364.681641,444.030304 2343.689697,444.034119 2322.697510,444.028229 C2316.246338,444.026428 2316.014404,443.808044 2316.014404,437.219147 C2316.015137,349.750580 2316.058105,262.281952 2315.937256,174.813583 C2315.930664,170.164124 2317.346436,168.858414 2321.891113,168.908417 C2341.381592,169.122910 2360.876221,169.000931 2380.845947,169.001526 z"/>
<path fill="#F69B04" opacity="1.000000" stroke="none" d=" M512.981018,488.951050 C516.591858,484.247070 520.241638,479.572327 523.805542,474.833038 C531.410339,464.720093 534.781067,452.832825 534.812317,440.660553 C535.109619,324.853851 535.088684,209.045868 534.852600,93.238861 C534.825439,79.932739 530.810730,67.338020 522.882446,56.273506 C522.340271,55.516911 522.633423,54.161774 522.811768,52.966721 C532.746765,66.097198 537.132141,80.735252 537.097900,97.310219 C536.859070,213.109009 537.071228,328.908752 536.783203,444.707336 C536.766357,451.480072 534.288208,458.484283 531.837341,464.957458 C528.213989,474.527679 522.099121,482.680542 514.226868,489.753632 C513.494446,489.452698 513.237732,489.201874 512.981018,488.951050 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M512.647461,489.038055 C513.237732,489.201874 513.494446,489.452698 513.919800,489.912903 C506.566620,496.667297 498.159180,501.780334 488.607025,504.629608 C483.231537,506.233032 477.625824,507.324402 472.046051,507.931946 C458.426300,509.414917 445.249451,507.433197 433.044342,500.998230 C424.097961,496.281494 415.389160,491.111206 406.608948,486.082886 C393.976715,478.848572 381.364380,471.579254 368.764679,464.288452 C356.062378,456.938293 343.425201,449.474762 330.683655,442.193573 C322.712036,437.638092 314.510223,433.482849 306.571594,428.872711 C293.298065,421.164520 280.196411,413.160675 266.937836,405.426239 C257.755310,400.069580 248.430557,394.956635 239.014374,389.399048 C240.352737,389.228546 242.091843,388.952698 243.301727,389.626770 C256.805481,397.150024 270.255615,404.771027 283.665619,412.460846 C308.448273,426.672119 333.209625,440.920685 357.951324,455.203186 C379.239807,467.492371 400.427094,479.958344 421.788544,492.118835 C429.139435,496.303467 436.519775,501.365479 444.535706,503.217438 C454.537109,505.528076 465.246460,505.819031 475.582489,505.381836 C489.668152,504.786011 501.482788,497.758301 512.647461,489.038055 z"/>
<path fill="#281F4A" opacity="1.000000" stroke="none" d=" M1441.719482,170.298798 C1442.302612,171.877975 1442.948853,173.800507 1442.951050,175.723755 C1443.006592,225.481339 1443.008179,275.239044 1442.944336,324.996613 C1442.941895,326.869507 1442.192749,328.741425 1441.453369,330.780579 C1441.090210,278.826324 1441.062012,226.705276 1441.058960,174.584229 C1441.058838,173.270309 1441.334961,171.956390 1441.719482,170.298798 z"/>
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M2059.285156,232.323334 C2100.247803,232.004745 2141.426758,231.984299 2182.605957,232.056213 C2184.622803,232.059738 2186.638428,232.871384 2188.822266,233.657455 C2148.397949,234.004074 2107.805420,234.024551 2067.213379,233.937744 C2064.641846,233.932251 2062.072266,233.091873 2059.285156,232.323334 z"/>
<path fill="#430043" opacity="1.000000" stroke="none" d=" M1821.915771,435.376678 C1821.579956,434.224945 1821.003906,432.622162 1821.002930,431.018982 C1820.969482,381.246521 1820.988525,331.474060 1821.026855,281.701599 C1821.027954,280.391724 1821.331299,279.082092 1821.718506,277.440002 C1821.943604,329.713562 1821.943481,382.319489 1821.915771,435.376678 z"/>
<path fill="#2E002E" opacity="1.000000" stroke="none" d=" M1744.130127,276.579529 C1744.451050,277.738739 1744.968384,279.351410 1744.969727,280.964539 C1745.012085,328.407440 1745.006958,375.850342 1744.982422,423.293274 C1744.981689,424.604614 1744.685913,425.915863 1744.323242,427.571960 C1744.107910,377.622253 1744.097046,327.327728 1744.130127,276.579529 z"/>
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M1148.413940,234.743088 C1149.288086,233.913574 1150.552002,232.148788 1151.829224,232.138947 C1172.376465,231.980743 1192.925293,232.041290 1213.473877,232.082840 C1214.107422,232.084122 1214.740112,232.415009 1215.717163,232.834854 C1214.148804,233.389725 1212.237549,233.966492 1210.324219,233.973190 C1191.774780,234.038177 1173.224854,233.989151 1154.675293,234.031494 C1152.719727,234.035950 1150.764893,234.417633 1148.413940,234.743088 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M2204.687988,168.654999 C2165.973389,168.963058 2126.965820,168.972794 2087.491943,168.968307 C2088.258301,168.637283 2089.490723,168.045258 2090.723877,168.043686 C2127.796387,167.996536 2164.868896,168.005508 2201.941406,168.024231 C2202.759277,168.024643 2203.577393,168.241028 2204.687988,168.654999 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1199.681885,168.656769 C1181.301147,168.965103 1162.629395,168.974197 1143.491089,168.969086 C1143.591187,168.664795 1144.158691,168.120102 1144.724731,168.121521 C1162.946899,168.167679 1181.168945,168.266556 1199.681885,168.656769 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.344482,208.687668 C1324.174561,198.985367 1324.302979,188.990707 1324.605469,178.658905 C1324.734009,188.346268 1324.688477,198.370789 1324.344482,208.687668 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.820923,324.249207 C1324.498901,316.934113 1324.232910,309.180481 1324.009399,301.425629 C1323.947144,299.269409 1324.010010,297.109589 1324.030762,294.484253 C1324.363892,297.208466 1324.900757,300.396149 1324.954712,303.591980 C1325.068115,310.329132 1324.928711,317.070526 1324.820923,324.249207 z"/>
<path fill="#FF00FF" opacity="1.000000" stroke="none" d=" M214.788666,124.825363 C214.496323,115.443848 214.374802,105.590721 214.562286,95.472626 C214.900681,104.923019 214.930084,114.638390 214.788666,124.825363 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M234.931915,387.307251 C235.796280,387.333801 236.750076,387.639465 237.613724,388.191864 C236.689499,388.154510 235.855423,387.870422 234.931915,387.307251 z"/>
<path fill="#221538" opacity="1.000000" stroke="none" d=" M211.949326,161.867874 C212.358459,162.578186 212.557846,163.400101 212.557709,164.221970 C212.545151,233.027054 212.517105,301.832153 212.377472,371.150421 C185.279068,356.126007 157.666046,341.570770 131.449005,324.829407 C87.927750,297.038086 89.674866,233.364563 134.183105,206.670563 C159.744293,191.340149 185.808609,176.848602 211.949326,161.867874 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M448.520416,291.576965 C445.481720,304.050262 438.862549,314.930573 430.206177,323.768677 C419.549103,334.649506 407.133850,343.417419 391.553467,347.250214 C382.719025,349.423523 373.992004,351.087280 365.039307,350.861145 C352.553436,350.545807 340.928680,346.711548 329.811554,340.975372 C315.864471,333.778931 304.943359,323.504425 296.341583,309.739136 C293.122955,302.430634 289.375671,295.847046 287.613251,288.769196 C283.192322,271.015137 283.284637,253.339325 290.253174,235.913177 C294.830750,224.466003 301.326721,214.610947 310.311005,206.244354 C329.706665,188.182205 352.128204,179.648621 378.927307,182.865051 C395.792664,184.889221 410.374329,191.639160 422.842163,202.571182 C436.770416,214.783829 446.404449,229.586563 450.512085,248.381592 C453.334473,261.295746 452.945831,273.469971 450.115540,286.313507 C450.094208,287.099060 450.145050,287.549835 450.121063,288.296448 C449.537628,289.587189 449.029022,290.582062 448.520416,291.576965 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M448.765869,291.723175 C449.029022,290.582062 449.537628,289.587189 450.257416,288.114380 C450.374969,287.083923 450.281342,286.531342 450.187683,285.978760 C452.945831,273.469971 453.334473,261.295746 450.512085,248.381592 C446.404449,229.586563 436.770416,214.783829 422.842163,202.571182 C410.374329,191.639160 395.792664,184.889221 378.927307,182.865051 C352.128204,179.648621 329.706665,188.182205 310.311005,206.244354 C301.326721,214.610947 294.830750,224.466003 290.253174,235.913177 C283.284637,253.339325 283.192322,271.015137 287.613251,288.769196 C289.375671,295.847046 293.122955,302.430634 296.022339,309.601044 C293.721344,307.120148 290.547760,304.626892 289.155273,301.371735 C284.214142,289.821014 281.409424,277.680542 282.017212,265.013763 C283.702362,229.894516 300.026855,203.834702 331.654480,188.451065 C367.770386,170.884338 406.602570,180.203156 432.394684,208.282761 C451.962433,229.585983 459.194275,255.296036 452.974182,284.127716 C452.588501,285.915344 452.481628,287.819763 451.762421,289.457977 C451.318390,290.469421 449.959442,291.079254 448.765869,291.723175 z"/>
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M450.115540,286.313507 C450.281342,286.531342 450.374969,287.083923 450.332275,287.818573 C450.145050,287.549835 450.094208,287.099060 450.115540,286.313507 z"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2 -6
View File
@@ -1,7 +1,3 @@
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1080" height="1080" fill="white"/>
<path d="M976 284.628C976 348.237 959.54 406.608 926.62 459.74C893.701 512.873 845.817 556.276 782.97 589.952C720.124 623.627 645.306 643.458 558.517 649.445L510.26 919.971C491.556 1023.99 440.68 1076 357.632 1076C311.993 1076 269.721 1062.53 230.816 1035.59C192.659 1008.65 161.984 967.49 138.79 912.113C115.597 856.736 104 788.637 104 707.816C104 555.902 128.316 427.187 176.947 321.671C226.327 215.407 292.166 136.082 374.466 83.6984C457.514 30.5661 548.791 4 648.299 4C718.627 4 778.107 16.3476 826.739 41.0429C876.118 65.7382 913.153 99.4136 937.843 142.069C963.281 183.976 976 231.496 976 284.628ZM578.718 533.826C732.094 514.369 808.783 434.671 808.783 294.731C808.783 245.34 792.323 205.304 759.403 174.622C727.231 143.192 677.103 127.476 609.019 127.476C531.957 127.476 464.621 151.798 407.012 200.44C350.15 249.082 306.008 316.807 274.584 403.615C243.909 489.674 228.571 588.081 228.571 698.836C228.571 745.233 233.06 786.392 242.039 822.312C251.765 858.232 263.736 886.295 277.951 906.501C292.915 925.957 307.13 935.686 320.597 935.686C339.302 935.686 353.517 909.868 363.243 858.232L400.278 646.078C371.099 641.587 358.38 639.717 362.121 640.465C339.676 636.723 325.086 629.988 318.353 620.26C311.619 609.783 308.252 596.687 308.252 580.972C308.252 564.508 312.741 551.412 321.719 541.684C331.446 531.955 344.539 527.091 360.999 527.091C368.481 527.091 374.092 527.465 377.833 528.214C395.789 531.207 409.63 533.078 419.357 533.826C429.083 475.455 442.924 397.254 460.88 299.221C465.369 273.777 475.47 255.817 491.182 245.34C507.641 234.115 526.72 228.503 548.417 228.503C573.107 228.503 590.689 233.367 601.163 243.095C612.386 252.075 617.997 266.668 617.997 286.873C617.997 298.847 617.249 308.575 615.753 316.059L578.718 533.826Z"
fill="black"/>
<path d="M304.835 467.541L426.099 489.952L411.851 580.937L391.091 699.816L266.088 676.643L304.835 467.541Z"
fill="white"/>
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="840" height="262">
<path d="M0 0 C8.69163006 7.71008755 13.14490087 16.89394432 14.22265625 28.37109375 C14.64705043 39.90354435 11.2436751 50.02540655 4 59 C-2.73007826 65.71611544 -11.11233981 69.18417403 -20.5625 69.4375 C-29.02193816 69.14854393 -34.47069789 67.31848169 -41 62 C-41.02505615 63.02472412 -41.0501123 64.04944824 -41.07592773 65.10522461 C-41.16984484 68.89409676 -41.27010317 72.68277881 -41.37231445 76.47143555 C-41.41572297 78.11364303 -41.45739358 79.75589739 -41.49731445 81.39819336 C-41.5548744 83.75388745 -41.61860892 86.10934428 -41.68359375 88.46484375 C-41.70866249 89.57266457 -41.70866249 89.57266457 -41.73423767 90.7028656 C-41.88612829 95.88612829 -41.88612829 95.88612829 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.34 -3.35 -41.68 -1.7 -41 0 C-40.5153125 -0.33773437 -40.030625 -0.67546875 -39.53125 -1.0234375 C-26.76996544 -9.51459992 -12.64739523 -8.64871948 0 0 Z M-39.875 17.0625 C-43.59522645 22.95180763 -44.64765142 30.01356041 -43.375 36.8125 C-41.94960016 41.95313555 -39.20005286 46.63995771 -35 50 C-28.58262778 53.05106644 -21.3538322 53.05583606 -14.625 50.9375 C-9.59434454 47.22439716 -5.93848019 43.00217489 -4 37 C-3.12848553 29.65293034 -3.43441364 23.03609731 -8 17 C-11.50353653 13.33721181 -15.41705741 10.34492712 -20.515625 9.6640625 C-28.47529099 9.55035299 -34.61737158 10.70443772 -39.875 17.0625 Z " fill="#070709" transform="translate(709,111)"/>
<path d="M0 0 C8.67817735 7.43616297 12.59651032 17.33058774 13.83984375 28.484375 C14.34206719 39.50879197 11.02283105 49.29470477 4.40234375 58.1328125 C-1.24768253 64.30376319 -9.32101987 68.57263825 -17.69921875 69.203125 C-27.77294256 69.3611442 -33.59239913 67.60506725 -42 62 C-42.33 73.55 -42.66 85.1 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.67 -3.02 -42.34 -1.04 -42 1 C-41.443125 0.59007813 -40.88625 0.18015625 -40.3125 -0.2421875 C-27.3317457 -9.40765253 -13.36734014 -9.00970502 0 0 Z M-39.875 17.3125 C-44.23157366 24.27418696 -44.76257396 31.08547435 -43 39 C-40.78638 44.12807721 -37.69263635 47.91273924 -33 51 C-27.15219658 52.94926781 -20.47788508 52.78007493 -14.625 50.9375 C-9.34602898 47.04111663 -5.31463083 42.57315414 -4 36 C-3.21564863 28.35613939 -4.61809235 21.89395052 -9.25 15.6875 C-13.66336941 11.37443444 -17.85242164 9.85182121 -23.9375 9.5625 C-30.64560394 9.80717248 -35.72726168 11.94341599 -39.875 17.3125 Z " fill="#08080A" transform="translate(792,111)"/>
<path d="M0 0 C0.33 -1.65 0.66 -3.3 1 -5 C6.61 -5 12.22 -5 18 -5 C18 18.76 18 42.52 18 67 C12.39 67 6.78 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-0.76183594 62.50660156 -0.76183594 62.50660156 -1.5390625 63.0234375 C-2.22742188 63.46945313 -2.91578125 63.91546875 -3.625 64.375 C-4.29789062 64.81585938 -4.97078125 65.25671875 -5.6640625 65.7109375 C-13.58368891 70.0812999 -23.10758184 70.39808303 -31.75 67.9375 C-41.62995371 64.03990358 -47.55506909 56.75034748 -52.25390625 47.54296875 C-56.35291308 38.0167055 -56.62716497 26.83848226 -53.375 17 C-49.4819062 7.90703965 -42.53172578 -0.6908008 -33.375 -4.875 C-21.19078419 -8.93640527 -10.59035975 -6.93997523 0 0 Z M-34 18 C-37.50770356 23.5121056 -38.64816392 29.16310107 -37.5625 35.58984375 C-35.7557313 42.41655504 -33.20468193 46.65511078 -27.4375 50.875 C-20.99668564 52.98290288 -13.87867289 53.19665826 -7.5625 50.5 C-2.42266422 47.51247045 0.16916612 42.49250164 2 37 C2.63422426 29.45029199 2.42141667 22.41441403 -2.359375 16.24609375 C-5.96089837 12.3939489 -9.32538157 10.29977528 -14.68359375 9.69921875 C-23.26189343 9.66271535 -28.25050093 11.31814973 -34 18 Z " fill="#070709" transform="translate(467,111)"/>
<path d="M0 0 C0.66 -1.65 1.32 -3.3 2 -5 C7.28 -5 12.56 -5 18 -5 C18.95704986 -1.17180055 19.12078914 1.77386172 19.11352539 5.69628906 C19.11367142 6.36011597 19.11381744 7.02394287 19.1139679 7.70788574 C19.11327332 9.8938325 19.10550231 12.07969319 19.09765625 14.265625 C19.09579045 15.78448439 19.09436731 17.30334438 19.09336853 18.82220459 C19.0895561 22.81377839 19.0797367 26.80531562 19.06866455 30.796875 C19.05842303 34.8723908 19.05386504 38.94791253 19.04882812 43.0234375 C19.03811327 51.01564362 19.0205268 59.00781305 19 67 C13.06 67 7.12 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-1.010625 62.78375 -2.02125 63.5675 -3.0625 64.375 C-10.10252051 69.32545267 -17.61014034 69.82953556 -26 69 C-34.99458455 66.97833698 -42.57884408 62.69673031 -48.04296875 55.1953125 C-54.42675603 44.84962837 -56.76481094 34.43070846 -54.5 22.328125 C-51.38068518 10.91426851 -45.31231302 1.98738781 -35.0078125 -4.1953125 C-22.99567478 -9.69420221 -10.51231494 -7.00820996 0 0 Z M-33.703125 18.0859375 C-37.69553255 23.97834623 -37.83104269 30.1032024 -37 37 C-35.40099464 42.03034735 -33.28906668 46.48368849 -28.875 49.5625 C-22.1750793 52.91246035 -15.37047301 52.88405849 -8.1875 51 C-3.21333977 47.87895829 0.14016889 43.57949332 2 38 C2.61486532 30.06722942 3.08216386 22.53421067 -2 16 C-6.55661235 11.56022386 -11.09995043 9.7144683 -17.4375 9.6875 C-25.03557191 9.87083149 -28.89903754 12.42951194 -33.703125 18.0859375 Z " fill="#080809" transform="translate(617,111)"/>
<path d="M0 0 C6.27 0 12.54 0 19 0 C19.86882812 2.09859375 20.73765625 4.1971875 21.6328125 6.359375 C22.49571507 8.43516367 23.36009471 10.51033151 24.22460938 12.58544922 C24.81566696 14.00635935 25.40523461 15.42789035 25.99316406 16.85009766 C29.67484115 25.75271955 33.54281316 34.45146929 38 43 C38.33209473 42.2251123 38.66418945 41.45022461 39.00634766 40.65185547 C42.10668945 33.41772461 42.10668945 33.41772461 45.20703125 26.18359375 C45.46780457 25.57501526 45.72857788 24.96643677 45.99725342 24.3394165 C46.52595131 23.10600543 47.05496437 21.8727294 47.58428955 20.6395874 C48.96437415 17.42332337 50.33821498 14.20459532 51.70214844 10.98144531 C51.97634613 10.33563507 52.25054382 9.68982483 52.53305054 9.02444458 C53.29495211 7.22937564 54.05452493 5.43331881 54.81396484 3.63720703 C56 1 56 1 57 0 C58.34267413 -0.08631874 59.68974322 -0.10706473 61.03515625 -0.09765625 C61.84404297 -0.09443359 62.65292969 -0.09121094 63.48632812 -0.08789062 C64.33646484 -0.07951172 65.18660156 -0.07113281 66.0625 -0.0625 C66.91650391 -0.05798828 67.77050781 -0.05347656 68.65039062 -0.04882812 C70.76695663 -0.03700373 72.88348685 -0.01906769 75 0 C73.16191407 5.91473461 70.9830678 11.44726207 68.25 17 C63.77345085 26.26927146 59.49910534 35.62503109 55.25 45 C54.92825806 45.70946777 54.60651611 46.41893555 54.27502441 47.14990234 C52.74027556 50.53437081 51.20654663 53.9192991 49.67382812 57.3046875 C47.45465601 62.20559819 45.22751178 67.10287487 43 72 C39.37 72 35.74 72 32 72 C26.23725499 60.94458283 21.08034555 49.63843719 16.02001953 38.24780273 C15.46135932 36.99067986 14.90260272 35.73359981 14.34375 34.4765625 C14.06707489 33.85417191 13.79039978 33.23178131 13.50534058 32.5905304 C11.61205991 28.34301709 9.67729179 24.11638141 7.71875 19.8984375 C7.20739502 18.79628906 6.69604004 17.69414063 6.16918945 16.55859375 C5.20099592 14.47830973 4.2283629 12.40008459 3.25073242 10.32421875 C2.60648315 8.93783203 2.60648315 8.93783203 1.94921875 7.5234375 C1.57112061 6.71825684 1.19302246 5.91307617 0.8034668 5.08349609 C0 3 0 3 0 0 Z " fill="#080809" transform="translate(250,106)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 35.64 17 71.28 17 108 C11.39 108 5.78 108 0 108 C0 72.36 0 36.72 0 0 Z " fill="#08080A" transform="translate(499,70)"/>
<path d="M0 0 C1.58203125 1.66796875 1.58203125 1.66796875 1.58203125 5.66796875 C-0.24609375 8.0625 -0.24609375 8.0625 -2.66796875 10.48046875 C-3.456875 11.29128906 -4.24578125 12.10210937 -5.05859375 12.9375 C-7.41796875 14.66796875 -7.41796875 14.66796875 -9.58984375 14.4921875 C-10.87844933 13.91121361 -12.15367922 13.30011351 -13.41796875 12.66796875 C-18.59558143 12.15189951 -23.07162336 12.11500546 -27.91796875 14.10546875 C-33.66913025 20.00040929 -33.87356406 28.09995626 -33.9296875 35.953125 C-33.9476387 36.8948204 -33.9655899 37.83651581 -33.98408508 38.80674744 C-34.03777974 41.78127717 -34.07195171 44.75565028 -34.10546875 47.73046875 C-34.13867794 49.76108932 -34.17317088 51.79168932 -34.20898438 53.82226562 C-34.29348231 58.77071953 -34.36048689 63.71913029 -34.41796875 68.66796875 C-40.02796875 68.66796875 -45.63796875 68.66796875 -51.41796875 68.66796875 C-51.41796875 44.90796875 -51.41796875 21.14796875 -51.41796875 -3.33203125 C-45.80796875 -3.33203125 -40.19796875 -3.33203125 -34.41796875 -3.33203125 C-34.08796875 -1.68203125 -33.75796875 -0.03203125 -33.41796875 1.66796875 C-32.67546875 1.04921875 -31.93296875 0.43046875 -31.16796875 -0.20703125 C-21.53059322 -7.65409416 -9.77403562 -6.65866875 0 0 Z " fill="#070709" transform="translate(412.41796875,109.33203125)"/>
<path d="M0 0 C3.62365347 3.99851417 3.44730059 8.25868181 3.28125 13.40625 C2.92171053 16.72200287 2.29410204 18.55295783 0 21 C-4.52162441 23.74080002 -7.47400283 24.31760393 -12.7109375 23.8359375 C-13.3530014 23.77965179 -13.99506531 23.72336609 -14.65658569 23.66537476 C-16.77451134 23.46887205 -18.8865471 23.23974308 -21 23 C-22.10486755 22.87558533 -22.10486755 22.87558533 -23.23205566 22.74865723 C-27.90893854 22.20777275 -32.5744168 21.59966694 -37.23779297 20.953125 C-39.65576921 20.62077073 -42.07569435 20.30634824 -44.49609375 19.9921875 C-46.05223146 19.77972796 -47.60822721 19.56622547 -49.1640625 19.3515625 C-50.23595322 19.21544052 -50.23595322 19.21544052 -51.32949829 19.0765686 C-55.51250758 18.4749119 -58.59393869 17.53147784 -62 15 C-63.43735246 12.12529507 -63.38172649 10.18105412 -63 7 C-58.54839678 1.78836696 -52.21129528 1.72201371 -45.8125 0.875 C-44.07432007 0.6279834 -44.07432007 0.6279834 -42.30102539 0.37597656 C-38.8697716 -0.10371979 -35.43624001 -0.55757005 -32 -1 C-31.06575195 -1.12149414 -30.13150391 -1.24298828 -29.16894531 -1.36816406 C-26.75978753 -1.6751507 -24.34975685 -1.96848703 -21.9375 -2.25 C-21.25598877 -2.33008301 -20.57447754 -2.41016602 -19.87231445 -2.49267578 C-4.80907763 -4.10186033 -4.80907763 -4.10186033 0 0 Z " fill="#FE7039" transform="translate(199,120)"/>
<path d="M0 0 C0.85850555 0.1038652 1.71701111 0.20773041 2.60153198 0.31474304 C4.40186289 0.53467384 6.20184063 0.75751383 8.00146484 0.9831543 C10.74057943 1.32401864 13.48138243 1.64772427 16.22265625 1.97070312 C17.98189995 2.18775813 19.74101944 2.4058228 21.5 2.625 C22.31058868 2.71888504 23.12117737 2.81277008 23.95632935 2.90950012 C28.99308043 3.56175949 33.10027078 4.64497567 37.6171875 7.01171875 C39.6171875 10.01171875 39.6171875 10.01171875 39.3671875 13.57421875 C38.6171875 17.01171875 38.6171875 17.01171875 36.6171875 19.01171875 C34.55200195 19.55834961 34.55200195 19.55834961 31.98046875 19.9765625 C31.02132568 20.13543945 30.06218262 20.29431641 29.07397461 20.45800781 C28.03635986 20.62010742 26.99874512 20.78220703 25.9296875 20.94921875 C24.86564697 21.1203418 23.80160645 21.29146484 22.70532227 21.46777344 C15.1682882 22.66019807 7.61816829 23.7511583 0.0546875 24.76171875 C-1.36106567 24.95302368 -1.36106567 24.95302368 -2.80541992 25.14819336 C-17.31186215 27.01302693 -17.31186215 27.01302693 -23.3828125 23.01171875 C-27.02637584 19.12525119 -26.88210576 14.75758051 -26.73828125 9.67578125 C-26.23821448 5.92802806 -24.9286388 3.76969724 -22.3828125 1.01171875 C-15.84749844 -2.95266934 -7.24058287 -0.89735419 0 0 Z " fill="#FE7139" transform="translate(48.3828125,117.98828125)"/>
<path d="M0 0 C2.69924999 1.79949999 3.97268929 2.98831891 5.625 5.75 C6.99344145 15.86914699 5.01332723 26.66429063 3.65356445 36.71191406 C3.30949244 39.27238125 2.98705575 41.83499184 2.66601562 44.3984375 C2.44920051 46.04704435 2.23114667 47.69548888 2.01171875 49.34375 C1.91783371 50.10006714 1.82394867 50.85638428 1.72721863 51.63562012 C1.09337 56.17414888 0.04108984 59.76523478 -2.375 63.75 C-5.4582635 65.29163175 -7.98378099 65.04747535 -11.375 64.75 C-15.22166785 61.31318029 -15.3272904 56.46613107 -15.98046875 51.62890625 C-16.10940018 50.73316391 -16.2383316 49.83742157 -16.37117004 48.91453552 C-16.64146628 47.01650682 -16.90586639 45.11763044 -17.16479492 43.21801758 C-17.55711089 40.35187129 -17.97132834 37.48954926 -18.38867188 34.62695312 C-22.01941843 9.12762749 -22.01941843 9.12762749 -19.36669922 3.67895508 C-14.53713333 -2.53836515 -6.89205685 -2.55261365 0 0 Z " fill="#FE723B" transform="translate(119.375,41.25)"/>
<path d="M0 0 C2.90652991 2.90652991 2.876536 6.26706827 3.4375 10.1875 C3.62372925 11.43889771 3.62372925 11.43889771 3.8137207 12.71557617 C4.21906167 15.47570672 4.60956571 18.23772456 5 21 C5.14115234 21.96454102 5.28230469 22.92908203 5.42773438 23.92285156 C9.97022281 55.07281183 9.97022281 55.07281183 5.8125 61.25 C0.715773 64.4212968 -2.9058144 65.63043299 -9 65 C-12.29013805 63.71053175 -15.20895719 62.21495778 -17.18669128 59.17736816 C-19.85591311 52.03139945 -17.79685661 42.79141254 -16.79296875 35.40625 C-16.60387688 33.9753302 -16.60387688 33.9753302 -16.41096497 32.51550293 C-16.14485659 30.52187219 -15.87493824 28.52874651 -15.60131836 26.53613281 C-15.18498991 23.48158379 -14.79028617 20.42471259 -14.39648438 17.3671875 C-14.134117 15.42169863 -13.87049062 13.47637904 -13.60546875 11.53125 C-13.48970993 10.61879517 -13.37395111 9.70634033 -13.25468445 8.76623535 C-13.13478653 7.92105347 -13.01488861 7.07587158 -12.89135742 6.20507812 C-12.7914299 5.46326294 -12.69150238 4.72144775 -12.58854675 3.95715332 C-11.8654499 1.55256711 -10.95241701 0.55505439 -9 -1 C-5.19825711 -2.26724763 -3.6488868 -1.4750819 0 0 Z " fill="#FE723B" transform="translate(117,156)"/>
<path d="M0 0 C5.94 0 11.88 0 18 0 C18 23.76 18 47.52 18 72 C12.06 72 6.12 72 0 72 C0 48.24 0 24.48 0 0 Z " fill="#070708" transform="translate(330,106)"/>
<path d="M0 0 C5.04330987 4.1028907 7.71774978 9.39439884 10.75 15.0625 C11.24371094 15.96033203 11.73742187 16.85816406 12.24609375 17.78320312 C17.43420605 27.34212236 17.43420605 27.34212236 16.3125 31.625 C15 34 15 34 13.3125 35.375 C7.93375248 36.82871554 3.75347415 35.06129862 -0.91796875 32.453125 C-2.68187675 31.39768267 -4.43827823 30.32960785 -6.1875 29.25 C-7.07630859 28.7240625 -7.96511719 28.198125 -8.88085938 27.65625 C-13.35431143 24.94058564 -17.21257396 22.56892147 -20 18 C-20.61105403 13.79900352 -20.33783835 11.64968914 -18.375 7.875 C-13.06220859 1.44372619 -8.6335148 -2.08395185 0 0 Z " fill="#FE713A" transform="translate(63,62)"/>
<path d="M0 0 C6.03297915 0.33114122 9.30476624 2.08342832 13.6171875 6.29296875 C16.38968616 9.47219093 17.04213903 11.96541114 17.65234375 16.0625 C17.06814734 20.15187485 15.13009417 22.36369578 12.21484375 25.125 C9.21149624 27.28908872 6.06168268 29.11960481 2.83984375 30.9375 C2.01484375 31.42669922 1.18984375 31.91589844 0.33984375 32.41992188 C-4.43543981 35.16002506 -8.25324942 36.89804998 -13.78515625 37.125 C-16.58087127 36.16857118 -17.46165114 35.77201022 -18.78515625 33.125 C-19.67120353 26.26932103 -16.95954186 21.84338012 -13.59765625 16 C-13.11490234 15.11957031 -12.63214844 14.23914063 -12.13476562 13.33203125 C-9.4652036 8.58056917 -6.87140939 4.73948324 -2.78515625 1.125 C-1.78515625 0.125 -1.78515625 0.125 0 0 Z " fill="#FE713A" transform="translate(163.78515625,60.875)"/>
<path d="M0 0 C2.78888858 1.43213197 3.75957557 2.28489145 4.76171875 5.265625 C5.06866999 8.7880163 4.15658074 10.4564111 2.5 13.5625 C1.94570312 14.60535156 1.39140625 15.64820313 0.8203125 16.72265625 C0.21960938 17.80417969 -0.38109375 18.88570312 -1 20 C-1.60070312 21.09183594 -2.20140625 22.18367188 -2.8203125 23.30859375 C-8.27249431 32.95022261 -8.27249431 32.95022261 -12 36 C-16.40910476 37.09025136 -20.18006761 36.37843033 -24.25 34.5 C-28.79178106 30.37110812 -31.58989016 26.41964808 -32.625 20.3125 C-31.84891159 16.19923141 -30.15611216 14.52356363 -27 12 C-23.60919652 9.73474885 -20.10610667 7.70297221 -16.5625 5.6875 C-15.63373047 5.13771484 -14.70496094 4.58792969 -13.74804688 4.02148438 C-5.16887017 -0.89398698 -5.16887017 -0.89398698 0 0 Z " fill="#FE713A" transform="translate(75,163)"/>
<path d="M0 0 C2.54541016 1.03808594 2.54541016 1.03808594 5.0390625 2.484375 C5.94914063 3.00773438 6.85921875 3.53109375 7.796875 4.0703125 C9.19679687 4.90175781 9.19679687 4.90175781 10.625 5.75 C11.56601563 6.28882813 12.50703125 6.82765625 13.4765625 7.3828125 C23.65542022 13.31084043 23.65542022 13.31084043 26 18 C26.23675113 23.05412198 25.48527501 26.97865449 22.25 30.9375 C18.41837476 34.85992204 15.48689762 35.78758605 10.0625 36.5625 C7.57335027 36.49114109 6.40429881 36.33826832 4.47973633 34.72802734 C0.68248769 30.09704219 -2.45567147 25.28818627 -5.375 20.0625 C-5.82488281 19.30259766 -6.27476562 18.54269531 -6.73828125 17.75976562 C-9.65990878 12.56033427 -10.65491643 8.96628872 -10 3 C-7.14254571 -0.51686681 -4.31419574 -0.2967049 0 0 Z " fill="#FE713A" transform="translate(155,163)"/>
<path d="M0 0 C3.53733123 2.55892046 4.75963588 4.1887711 6 8.375 C6 12.7289874 5.17207574 15.23506872 3 19 C0.12121032 21.47895778 -1.66317695 21.95713161 -5.4375 22.4375 C-9.70968693 21.91284546 -11.75031771 20.79623825 -15 18 C-17.10863429 14.35781349 -17.50962203 11.16191328 -17 7 C-15.27978264 3.68698879 -13.9086019 1.58049566 -10.75 -0.4375 C-6.93940558 -1.21693977 -3.72934532 -1.15270673 0 0 Z " fill="#060608" transform="translate(548,158)"/>
<path d="M0 0 C2.66915647 2.14834545 3.77943987 3.55633371 4.30078125 6.96875 C4.43294773 10.69723583 4.46252586 14.10920878 2 17 C-2.01955829 19.36444606 -4.10903676 20.14849387 -8.75 19.375 C-12.50920399 17.78456754 -14.19878159 16.60243682 -16 13 C-16.4128469 8.70639223 -16.2493583 6.46432234 -14.1875 2.625 C-10.27705848 -2.06752982 -5.52304925 -1.42562463 0 0 Z " fill="#060608" transform="translate(345,74)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C19 5 19 5 19 8 C18.01 8 17.02 8 16 8 C16 5.69 16 3.38 16 1 C11.05 1 6.1 1 1 1 C1 34 1 67 1 101 C5.62 101 10.24 101 15 101 C15 101.33 15 101.66 15 102 C10.05 102 5.1 102 0 102 C0 68.34 0 34.68 0 0 Z " fill="#000000" transform="translate(649,106)"/>
<path d="M0 0 C1.48831242 0.00667405 2.97660807 0.01863442 4.46484375 0.03515625 C5.22345703 0.03966797 5.98207031 0.04417969 6.76367188 0.04882812 C8.64326246 0.06064945 10.52281274 0.07858547 12.40234375 0.09765625 C13.52283021 3.69273097 13.52281589 7.0427002 13.51586914 10.79394531 C13.51601517 11.45777222 13.51616119 12.12159912 13.51631165 12.80554199 C13.51561707 14.99148875 13.50784606 17.17734944 13.5 19.36328125 C13.4981342 20.88214064 13.49671106 22.40100063 13.49571228 23.91986084 C13.49189985 27.91143464 13.48208045 31.90297187 13.4710083 35.89453125 C13.46076678 39.97004705 13.45620879 44.04556878 13.45117188 48.12109375 C13.44045702 56.11329987 13.42287055 64.1054693 13.40234375 72.09765625 C7.46234375 72.09765625 1.52234375 72.09765625 -4.59765625 72.09765625 C-4.92765625 69.78765625 -5.25765625 67.47765625 -5.59765625 65.09765625 C-4.11265625 64.60265625 -4.11265625 64.60265625 -2.59765625 64.09765625 C-2.59765625 66.40765625 -2.59765625 68.71765625 -2.59765625 71.09765625 C2.02234375 71.09765625 6.64234375 71.09765625 11.40234375 71.09765625 C11.40234375 47.99765625 11.40234375 24.89765625 11.40234375 1.09765625 C6.78234375 1.09765625 2.16234375 1.09765625 -2.59765625 1.09765625 C-2.59765625 3.40765625 -2.59765625 5.71765625 -2.59765625 8.09765625 C-3.25765625 8.09765625 -3.91765625 8.09765625 -4.59765625 8.09765625 C-4.64007348 5.7647085 -4.63858562 3.43063058 -4.59765625 1.09765625 C-3.59765625 0.09765625 -3.59765625 0.09765625 0 0 Z " fill="#000000" transform="translate(622.59765625,105.90234375)"/>
<path d="M0 0 C0.66 0.66 1.32 1.32 2 2 C0.3603125 1.9071875 0.3603125 1.9071875 -1.3125 1.8125 C-4.8362826 1.70189764 -4.8362826 1.70189764 -6.875 3.125 C-8.40430823 5.67384706 -8.67470941 8.07238473 -9 11 C-9.33 11 -9.66 11 -10 11 C-10.48541424 3.59743291 -10.48541424 3.59743291 -8.375 1 C-5.38358852 -0.25954167 -3.21567216 -0.34952958 0 0 Z " fill="#FE5D28" transform="translate(155,163)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.38125 1.598125 1.7625 2.19625 1.125 2.8125 C-1.13676954 5.00811144 -1.13676954 5.00811144 -3 8 C-3.36923077 3.44615385 -3.36923077 3.44615385 -1.5 1.1875 C-1.005 0.795625 -0.51 0.40375 0 0 Z " fill="#FE6B2D" transform="translate(139,124)"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+78
View File
@@ -0,0 +1,78 @@
# Plunk
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
## Product
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
## Features
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
## Comparisons
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
- [All comparisons](https://www.useplunk.com/vs)
## Guides
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
- [All guides](https://www.useplunk.com/guides)
## Tools
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
- [MX checker](https://www.useplunk.com/tools/mx-checker)
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
- [All tools](https://www.useplunk.com/tools)
## Optional
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
- [Discord community](https://www.useplunk.com/discord)
- [Privacy policy](https://www.useplunk.com/privacy)
- [Terms of service](https://www.useplunk.com/terms)
- [Data processing agreement](https://www.useplunk.com/dpa)
+14 -19
View File
@@ -13,26 +13,21 @@ interface ComparisonTableProps {
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={'overflow-hidden rounded-[24px] border border-neutral-200'}>
<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 className={'bg-neutral-50 p-6'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Feature</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
<div className={'bg-neutral-900 p-6 text-center'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-white'}>Plunk</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
<div className={'bg-neutral-50 p-6 text-center'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>{competitorName}</span>
</div>
</div>
{/* Rows */}
<div className={'grid gap-px bg-neutral-200'}>
{rows.map((row, index) => (
<motion.div
@@ -40,22 +35,22 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
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]}}
transition={{duration: 0.4, delay: index * 0.04, 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>
<span className={'text-sm text-neutral-700'}>{row.feature}</span>
</div>
<div className={'bg-white p-6'}>
<div className={'bg-neutral-50/70 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} />
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
<span className={'text-sm font-medium text-neutral-900'}>{row.plunk}</span>
)}
</div>
</div>
@@ -65,10 +60,10 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
row.competitor ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
<span className={'text-sm text-neutral-600'}>{row.competitor}</span>
)}
</div>
</div>
+28 -21
View File
@@ -12,9 +12,6 @@ interface FAQSectionProps {
schemaId?: string;
}
/**
* Reusable FAQ section component with structured data support
*/
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
return (
<>
@@ -37,34 +34,44 @@ export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
}}
/>
<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>
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Frequently asked questions
</h2>
</motion.div>
<div className={'space-y-8'}>
<div className={'mx-auto max-w-3xl divide-y divide-neutral-200'}>
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{opacity: 0, y: 20}}
initial={{opacity: 0, y: 16}}
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'}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={'py-8'}
>
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-lg font-bold tracking-[-0.02em] text-neutral-900'}
>
{faq.question}
</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{faq.answer}</p>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
</>
);
+39 -8
View File
@@ -1,5 +1,6 @@
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {WIKI_URI} from '../../lib/constants';
import logo from '../../../public/assets/logo.svg';
@@ -7,10 +8,14 @@ import logo from '../../../public/assets/logo.svg';
*
*/
export default function Footer() {
const router = useRouter();
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
const mdHref = `${trimmed}.md`;
return (
<>
<footer className={'border-t border-neutral-200 bg-white'}>
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
<div className="mx-auto max-w-[88rem] px-6 py-20 sm:px-10">
<div className="grid gap-12 lg:grid-cols-12">
{/* Logo and description */}
<div className="space-y-6 lg:col-span-3">
@@ -59,7 +64,7 @@ export default function Footer() {
{/* Links */}
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
<div>
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Product</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/pricing'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -87,10 +92,29 @@ export default function Footer() {
</Link>
</li>
</ul>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Checkers</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/tools/spf-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
SPF checker
</Link>
</li>
<li>
<Link href={'/tools/dmarc-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
DMARC checker
</Link>
</li>
<li>
<Link href={'/tools/dkim-checker'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
DKIM checker
</Link>
</li>
</ul>
</div>
<div>
<h3 className="text-sm font-semibold text-neutral-900">Features</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Features</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/features/email-editor'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -121,7 +145,7 @@ export default function Footer() {
</div>
<div>
<h3 className="text-sm font-semibold text-neutral-900">Compare</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Compare</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/vs'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -162,7 +186,7 @@ export default function Footer() {
</div>
<div>
<h3 className="text-sm font-semibold text-neutral-900">Community</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Community</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/discord'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -181,7 +205,7 @@ export default function Footer() {
</li>
</ul>
<h3 className="mt-8 text-sm font-semibold text-neutral-900">Legal</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Legal</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -202,7 +226,7 @@ export default function Footer() {
</div>
<div>
<h3 className="text-sm font-semibold text-neutral-900">Guides</h3>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Guides</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/guides/email-deliverability'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -244,8 +268,15 @@ export default function Footer() {
</div>
</div>
<div className="mt-16 border-t border-neutral-200 pt-8">
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p>
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
Reading this with electronic eyes? Append{' '}
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
<code>.md</code>
</a>{' '}
to any URL for the Markdown cut.
</p>
</div>
</div>
</footer>
@@ -47,8 +47,8 @@ export default function Navbar() {
const [featuresOpen, setFeaturesOpen] = useState(false);
return (
<header className={'sticky top-0 z-40 w-full border-b border-neutral-100 bg-white/95 backdrop-blur-sm'}>
<div className={'relative mx-auto max-w-7xl px-8 xl:px-0'}>
<header className={'sticky top-0 z-40 w-full border-b border-neutral-200 bg-white/95 backdrop-blur-sm'}>
<div className={'relative mx-auto max-w-[88rem] px-6 sm:px-10'}>
<div className={'py-5'}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-12">
@@ -83,14 +83,14 @@ export default function Navbar() {
onMouseEnter={() => setFeaturesOpen(true)}
onMouseLeave={() => setFeaturesOpen(false)}
className={
'absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg'
'absolute left-0 top-full z-50 mt-2 w-80 rounded-[16px] border border-neutral-200 bg-white p-2 shadow-lg'
}
>
{featuresMenu.map(feature => (
<Link
key={feature.href}
href={feature.href}
className={'flex items-start gap-3 rounded-lg p-3 transition hover:bg-neutral-50'}
className={'flex items-start gap-3 rounded-[10px] p-3 transition hover:bg-neutral-50'}
>
<div
className={
@@ -166,7 +166,7 @@ export default function Navbar() {
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'
'rounded-full bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-neutral-800'
}
>
Get started
@@ -226,7 +226,7 @@ export default function Navbar() {
className="space-y-1 p-4"
>
<div className="mb-2">
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">
<div style={{fontFamily: 'var(--font-mono)'}} className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">
Features
</div>
{featuresMenu.map(feature => (
@@ -293,7 +293,7 @@ export default function Navbar() {
</a>
<a
href={`${DASHBOARD_URI}/auth/signup`}
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
className="mt-2 block rounded-full bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
>
Get started
</a>
@@ -0,0 +1,48 @@
import {motion} from 'framer-motion';
import React from 'react';
export function SectionHeader({
number,
label,
title,
titleAccent,
subtitle,
}: {
number: string;
label: string;
title: string;
titleAccent?: string;
subtitle?: string;
}) {
return (
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true, margin: '-10%'}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-8 lg:grid-cols-12 lg:gap-16'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={
'flex items-center gap-4 border-t border-neutral-900 pt-4 text-[11px] uppercase tracking-[0.2em] text-neutral-700 lg:col-span-3 lg:self-start'
}
>
<span className={'font-medium text-neutral-900'}>§ {number}</span>
<span className={'text-neutral-500'}>{label}</span>
</div>
<div className={'lg:col-span-9'}>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.25rem,5.5vw,4.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'
}
>
{title}
{titleAccent && <> {titleAccent}</>}
</h2>
{subtitle && <p className={'mt-6 max-w-2xl text-lg leading-relaxed text-neutral-600'}>{subtitle}</p>}
</div>
</motion.div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import {AnimatePresence, motion} from 'framer-motion';
import React, {useEffect, useState} from 'react';
import {ArrowRight, Gift, X} from 'lucide-react';
import {DASHBOARD_URI} from '../lib/constants';
interface SwitchOfferProps {
competitorName: string;
}
export function SwitchOffer({competitorName}: SwitchOfferProps) {
const storageKey = `switch-offer-dismissed:${competitorName}`;
const [visible, setVisible] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
if (window.localStorage.getItem(storageKey) === '1') return;
const t = window.setTimeout(() => setVisible(true), 800);
return () => window.clearTimeout(t);
}, [storageKey]);
const dismiss = () => {
setVisible(false);
if (typeof window !== 'undefined') {
window.localStorage.setItem(storageKey, '1');
}
};
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{opacity: 0, y: 24, scale: 0.96}}
animate={{opacity: 1, y: 0, scale: 1}}
exit={{opacity: 0, y: 16, scale: 0.97}}
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
className={
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
}
role="dialog"
aria-label={`Switch from ${competitorName} to Plunk offer`}
>
<div
className={
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
}
>
<button
type="button"
onClick={dismiss}
aria-label="Dismiss offer"
className={
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
}
>
<X className="h-3.5 w-3.5" />
</button>
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
<div
className={
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
}
>
<Gift className="h-3 w-3 text-neutral-700" />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
>
Switching offer
</span>
</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
>
Switching from {competitorName}?
<br />
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
</h3>
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
Sign up and enter this code at checkout to redeem 2,000 email credits.
</p>
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
>
SWITCH
</span>
</div>
</div>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
}
>
Claim your credits
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
</motion.a>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -1,4 +1,5 @@
import {Footer, Navbar} from '../';
import {FAQSection, Footer, Navbar} from '../';
import type {FAQ} from '../FAQSection';
import {motion} from 'framer-motion';
import React, {ReactNode, useLayoutEffect, useState} from 'react';
import Link from 'next/link';
@@ -13,6 +14,7 @@ interface GuideLayoutProps {
children: ReactNode;
canonical?: string;
ogImage?: string;
faqs?: FAQ[];
}
/**
@@ -25,8 +27,12 @@ export function GuideLayout({
readTime,
children,
canonical,
ogImage = 'https://www.useplunk.com/assets/card.png',
ogImage,
faqs,
}: GuideLayoutProps) {
const resolvedOgImage =
ogImage ||
`https://www.useplunk.com/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&tag=Guide`;
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
@@ -90,7 +96,7 @@ export function GuideLayout({
description: description,
url: canonical,
type: 'article',
images: [{url: ogImage, alt: title}],
images: [{url: resolvedOgImage, alt: title, width: 1200, height: 630}],
article: {
publishedTime: lastUpdated,
modifiedTime: lastUpdated,
@@ -103,7 +109,7 @@ export function GuideLayout({
type="Article"
url={canonical || ''}
title={title}
images={[ogImage]}
images={[resolvedOgImage]}
datePublished={lastUpdated}
dateModified={lastUpdated}
authorName="Plunk"
@@ -114,7 +120,7 @@ export function GuideLayout({
<Navbar />
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
<main className={'mx-auto max-w-[88rem] 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'}>
@@ -148,7 +154,10 @@ export function GuideLayout({
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'}>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-3xl sm:text-4xl font-bold tracking-[-0.02em] text-neutral-900 break-words max-w-full'}
>
{title}
</h1>
<p
@@ -199,16 +208,16 @@ export function GuideLayout({
<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'}>
<ul className={'space-y-0.5'}>
{headings.map(heading => (
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
<li key={heading.id} className={heading.level === 3 ? 'ml-3' : ''}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const offset = 100; // Account for fixed header
const offset = 100;
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - offset,
@@ -216,14 +225,14 @@ export function GuideLayout({
});
}
}}
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
className={`block rounded px-2 py-1.5 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'
? 'bg-neutral-100 text-sm font-semibold text-neutral-900'
: 'text-sm font-medium text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
: 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'
? 'bg-neutral-50 text-xs font-medium text-neutral-800'
: 'text-xs text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
}`}
>
{heading.text}
@@ -238,6 +247,13 @@ export function GuideLayout({
</div>
</main>
{faqs && faqs.length > 0 && (
<FAQSection
faqs={faqs}
schemaId={`faq-${canonical ? canonical.split('/').pop() : 'guide'}`}
/>
)}
<Footer />
</>
);
+2
View File
@@ -3,3 +3,5 @@ export * from './Footer';
export * from './ComparisonTable';
export * from './FAQSection';
export * from './CodeBlock';
export * from './SectionHeader';
export * from './SwitchOffer';
@@ -0,0 +1,14 @@
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
'index',
'pricing',
'features/workflows',
'features/segments',
'features/inbound-email',
'features/email-editor',
'features/smtp',
]);
export function hasMarkdownVariant(pathname: string): boolean {
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
return MARKDOWN_SLUGS.has(slug);
}
+345
View File
@@ -0,0 +1,345 @@
export const MARKDOWN_PAGES: Record<string, string> = {
index: `# Plunk — The Open-Source Email Platform
Transactional emails, marketing campaigns, and workflow automation — in one platform.
Self-hostable, $0.001 per email, no contact limits.
[Get started free](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
---
## Key facts
| Metric | Value |
|--------|-------|
| Price | $0.001 / email |
| GitHub stars | 5,000+ |
| Contacts | Unlimited, always free |
| Setup time | < 5 minutes |
| License | AGPL-3.0 |
---
## Replace your email stack
One tool in place of Resend, SendGrid, Mailchimp, Customer.io, and Mailgun.
- [Plunk vs Resend](/vs/resend)
- [Plunk vs SendGrid](/vs/sendgrid)
- [Plunk vs Mailchimp](/vs/mailchimp)
- [Plunk vs Customer.io](/vs/customerio)
- [Plunk vs Mailgun](/vs/mailgun)
---
## Why Plunk
### Setup — < 5 min
Most email platforms take days to configure. Plunk is running in under five minutes — domain, DKIM, and first send included.
### Pricing — 0 limits
Other platforms charge more as your list grows. Plunk stores unlimited contacts for free and bills only on send.
### Ownership — AGPL-3.0
Closed-source platforms own your stack. Plunk is fully inspectable, forkable, and self-hostable on your own infra.
---
## Features
### Workflow Automation
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
### Dynamic Segments
Real-time audience segmentation based on contact data and behavior.
### Campaign Management
Broadcast emails with scheduling and performance tracking.
### Analytics
Detailed metrics on opens, clicks, bounces, and conversions across campaigns.
### Inbound Email
Receive and process incoming emails with webhook notifications.
### Custom Domains
Brand consistency with DKIM authentication and custom sending domains.
---
## Data model
Every interaction flows into a single contact record — transactional, campaign, and workflow events unified in one source of truth.
- **Transactional** — Receipts, password resets, event-driven sends
- **Campaigns** — Newsletters, product launches, announcements
- **Workflows** — Onboarding sequences, drip campaigns
---
## Open source
- **License**: AGPL-3.0
- **Hosting**: EU-hosted, GDPR compliant
- **Deployment**: Self-hostable via Docker Compose
- **Community**: 5,000+ GitHub stars
[View on GitHub](https://github.com/useplunk/plunk)
---
## Pricing comparison
**Plunk: $0.001 / email**
| Provider | Price / email |
|----------|--------------|
| Plunk | $0.001 |
| Mailgun | $0.003 |
| SendGrid | $0.002 |
| Mailchimp | $0.004 |
*Based on plans matching Plunk at 10,000 emails per month.*
[Full pricing details](/pricing)
---
## Testimonials
> "Transparent and intuitive UI, extremely easy setup & automation and great support."
> — Artur Czemiel, Founder at GraphQL Editor
> "I've been using Plunk for building & sending out marketing emails and genuinely love it!"
> — Joe Ashwell, Founder at UnwindHR
> "I loved the ease of use, beautiful UI and great UX. Everything simply works."
> — Alisson Leal, Founder at Brapi
> "Clean design, easy to understand, fair pricing."
> — Pierre Jacquel, Founder at Landingly
> "Simple to use, efficient and no regrets!"
> — Noah Di Gesu, Founder at Smoothey
> "Lots of care put into Plunk"
> — Jonni Lundy, Founding Operations Manager at Resend
---
## FAQ
**How is Plunk different from other email automation tools?**
Plunk is built for SaaS businesses, indie hackers, and developers. Create complex automated email flows and trigger them from anywhere through a single API call.
**Can I use Plunk for transactional emails?**
Yes. Plunk handles transactional emails — sign-ups, cancellations, plan changes — alongside marketing emails in the same platform.
**Can I use Plunk for newsletters?**
Yes. Plunk lets you send newsletters and broadcast emails to your full list or any segment.
**What programming languages does Plunk support?**
Any language capable of HTTP requests. Plunk provides a REST API plus SDKs for Node.js, Python, and more.
**How much does Plunk cost?**
Free plan includes 1,000 emails/month. Paid plan is $0.001 per email with no contact limits.
---
Start sending in 5 minutes. Free plan available. No contact limits, no surprises.
[Create free account](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
`,
pricing: `# Plunk Pricing — Simple, Transparent
Free plan: 1,000 emails/month. Paid plan: $0.001/email. Unlimited contacts, no hidden fees.
---
## Plans
### Free forever
**1,000 emails / month** — No credit card required
Includes:
- Transactional emails
- Workflow automation
- Campaign broadcasts
- Custom domains
- Click & open tracking
- Unlimited contacts
- Plunk branding on emails (removed on paid)
[Start for free](https://next-app.useplunk.com/auth/signup)
---
### Pay as you grow
**$0.001 / email** — No base fee
Includes everything in Free, plus:
- No Plunk branding
- Monthly spend cap
- Unlimited emails
[Get started](https://next-app.useplunk.com/auth/signup)
---
## Every feature, every plan
No feature tiers, no add-ons, no surprises.
| Feature | Description |
|---------|-------------|
| Transactional emails | API and SMTP delivery for receipts, password resets, and event-driven email. |
| Workflow automation | Event-triggered sequences with delays, conditions, and branching logic. |
| Campaign broadcasts | Send newsletters and announcements to your full list or a targeted segment. |
| Unlimited contacts | Store as many contacts as you need. Growing your list never costs more. |
| Full API access | REST API with SDKs for Node.js, Python, and more. |
| Custom domains | Send from your own domain with DKIM, SPF, and DMARC set up automatically. |
| Audience segmentation | Dynamic segments built on behavior, attributes, and engagement data. |
| Analytics & tracking | Opens, clicks, bounces, and unsubscribes. Real data, no guessing. |
| Open source | AGPL-3.0 licensed. Inspect the code, self-host it, or contribute. |
---
## Self-host for free
Run Plunk on your own infrastructure — full data ownership, no per-email costs, GDPR compliance by default.
Deploy with Docker Compose in minutes.
[View on GitHub](https://github.com/useplunk/plunk)
---
Start sending in 5 minutes. Free plan, no credit card required.
[Create free account](https://next-app.useplunk.com/auth/signup) | [Self-host for free](https://github.com/useplunk/plunk)
`,
'features/workflows': `# Workflow Automation — Plunk
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
[Get started free](https://next-app.useplunk.com/auth/signup) | [Documentation](https://docs.useplunk.com)
---
## What are workflows?
Workflows are automated email sequences that trigger based on events in your application. Define who receives emails, when they receive them, and what conditions must be met — all without writing code.
## Key capabilities
- **Event-triggered**: Start workflows from any API event
- **Delays**: Wait minutes, hours, or days between steps
- **Conditional branching**: Branch paths based on contact data or behavior
- **Visual builder**: Drag-and-drop interface, no code required
- **Unlimited steps**: As complex as your use case demands
## Use cases
- **Onboarding sequences**: Welcome new users and guide them to activation
- **Drip campaigns**: Nurture leads over time with educational content
- **Re-engagement**: Win back inactive contacts automatically
- **Lifecycle emails**: Upgrades, renewals, cancellations
[Back to features](/features) | [Pricing](/pricing)
`,
'features/segments': `# Dynamic Segments — Plunk
Real-time audience segmentation based on contact data and behavior.
[Get started free](https://next-app.useplunk.com/auth/signup)
---
## What are segments?
Segments are dynamic groups of contacts that update automatically as contact data changes. Target campaigns and workflows to exactly the right audience without manual list management.
## Capabilities
- **Attribute-based**: Filter by any contact property
- **Behavior-based**: Segment by email opens, clicks, and engagement
- **Real-time**: Segments update instantly as contact data changes
- **Combinable**: AND/OR logic for complex targeting
- **Unlimited contacts**: No per-segment contact limits
[Back to features](/features) | [Pricing](/pricing)
`,
'features/inbound-email': `# Inbound Email — Plunk
Receive and process incoming emails with webhook notifications.
[Get started free](https://next-app.useplunk.com/auth/signup)
---
## What is inbound email?
Inbound email lets your application receive emails sent to your Plunk domain. When a message arrives, Plunk parses it and delivers the content to your webhook endpoint in real time.
## Capabilities
- **Webhook delivery**: Parsed email payload posted to your endpoint
- **Attachment handling**: Access attachments programmatically
- **Custom addresses**: Route different addresses to different webhooks
- **Reply detection**: Track email threads automatically
[Back to features](/features) | [Pricing](/pricing)
`,
'features/email-editor': `# Email Editor — Plunk
Design beautiful emails with a drag-and-drop builder or write in Markdown.
[Get started free](https://next-app.useplunk.com/auth/signup)
---
## What is the email editor?
Plunk's email editor lets you build professional emails without writing HTML. Choose from the visual drag-and-drop builder or write in Markdown — both export to responsive HTML email.
## Capabilities
- **Drag-and-drop builder**: Compose layouts with blocks and columns
- **Markdown mode**: Write in plain text, render as rich email
- **Responsive**: Emails adapt to any screen size automatically
- **Preview**: See how your email looks on desktop and mobile
- **Reusable templates**: Save and reuse your designs
[Back to features](/features) | [Pricing](/pricing)
`,
'features/smtp': `# SMTP — Plunk
Send emails through Plunk using standard SMTP — no API changes required.
[Get started free](https://next-app.useplunk.com/auth/signup)
---
## SMTP access
Plunk provides SMTP credentials so you can send emails from any application or framework that supports standard SMTP. Drop in your Plunk credentials and start sending immediately — no code changes needed beyond configuration.
## Details
- **Host**: SMTP endpoint provided in your dashboard
- **Port**: 587 (TLS) or 465 (SSL)
- **Authentication**: Username and password from your Plunk project
- **Compatible with**: Any language, framework, or tool that supports SMTP
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
`,
};
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
File diff suppressed because one or more lines are too long
+72
View File
@@ -0,0 +1,72 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { hasMarkdownVariant } from './content/markdown-slugs';
type Negotiated = 'markdown' | 'html' | 'none';
function parseAccept(accept: string): Array<{ type: string; q: number }> {
return accept.split(',').map(part => {
const segments = part.trim().split(';');
const type = segments[0]?.trim().toLowerCase() ?? '';
const qParam = segments.slice(1).find(s => s.trim().startsWith('q='));
const q = qParam ? parseFloat(qParam.split('=')[1] ?? '1') : 1;
return { type, q: isNaN(q) ? 1 : q };
});
}
function getQ(types: Array<{ type: string; q: number }>, target: string): number {
const exact = types.find(t => t.type === target);
if (exact) return exact.q;
const [main] = target.split('/');
const sub = types.find(t => t.type === `${main}/*`);
if (sub) return sub.q;
const wildcard = types.find(t => t.type === '*/*');
return wildcard ? wildcard.q : -1;
}
function negotiate(accept: string): Negotiated {
if (!accept) return 'html';
const types = parseAccept(accept);
const mdQ = types.find(t => t.type === 'text/markdown')?.q ?? -1;
const htmlQ = getQ(types, 'text/html');
if (mdQ <= 0 && htmlQ <= 0) return 'none';
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
return 'html';
}
export function middleware(request: NextRequest) {
const accept = request.headers.get('accept') ?? '';
const { pathname } = request.nextUrl;
if (pathname.endsWith('.md')) {
const headers = new Headers(request.headers);
headers.set('x-md-path', pathname.slice(0, -3));
return NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
}
const result = negotiate(accept);
if (result === 'none') {
return new NextResponse(null, { status: 406, headers: { 'Vary': 'Accept' } });
}
if (result === 'markdown') {
const headers = new Headers(request.headers);
headers.set('x-md-path', pathname);
const response = NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
response.headers.set('Vary', 'Accept');
return response;
}
const response = NextResponse.next();
response.headers.set('Vary', 'Accept');
if (hasMarkdownVariant(pathname)) {
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
}
return response;
}
export const config = {
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
};
+39 -3
View File
@@ -2,11 +2,35 @@ import '../styles/globals.css';
import React, {useEffect} from 'react';
import Head from 'next/head';
import {AppProps} from 'next/app';
import {useRouter} from 'next/router';
import {toast, Toaster} from 'sonner';
import {SWRConfig} from 'swr';
import {network} from '../lib/network';
import {hasMarkdownVariant} from '../content/markdown-slugs';
import {DefaultSeo} from 'next-seo';
import Script from 'next/script';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
/**
* Main app component
@@ -15,6 +39,10 @@ import Script from 'next/script';
* @param props.pageProps
*/
function App({Component, pageProps}: AppProps) {
const router = useRouter();
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
const message = searchParams.get('message');
@@ -25,15 +53,16 @@ function App({Component, pageProps}: AppProps) {
}, []);
return (
<>
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<Head>
<title>Plunk | The Open-Source Email Platform</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
</Head>
<Toaster position={'top-right'} />
<Component {...pageProps} />
</>
</div>
);
}
@@ -60,7 +89,14 @@ export default function WithProviders(props: AppProps) {
title: 'Plunk | The Open-Source Email Platform',
description:
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
images: [
{
url: `https://www.useplunk.com/api/og?title=${encodeURIComponent('The Open-Source Email Platform')}`,
width: 1200,
height: 630,
alt: 'Plunk',
},
],
}}
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
/>
+2 -9
View File
@@ -6,21 +6,14 @@ export default class MyDocument extends Document {
return (
<Html lang="en">
<Head>
{/* Start fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
{/* End fonts */}
{/* Start favicon */}
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="msapplication-TileColor" content="#ffffff" />
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
<meta name="theme-color" content="#171717" />
+24
View File
@@ -0,0 +1,24 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MARKDOWN_PAGES } from '../../content/pages';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
const pathname = req.headers['x-md-path'];
if (typeof pathname !== 'string') {
res.status(406).end();
return;
}
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
res.setHeader('Vary', 'Accept');
const content = MARKDOWN_PAGES[slug];
if (!content) {
res.status(406).end();
return;
}
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.status(200).send(content);
}
+139
View File
@@ -0,0 +1,139 @@
import {ImageResponse} from 'next/og';
import type {NextRequest} from 'next/server';
import {getBricolageFont} from '../../lib/og-font';
export const config = {runtime: 'edge'};
const PLUNK_P_PATH =
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
export default function handler(req: NextRequest) {
const {searchParams} = new URL(req.url);
const title = searchParams.get('title') || 'The Open-Source Email Platform';
const description = searchParams.get('description') || '';
const tag = searchParams.get('tag') || '';
const fontData = getBricolageFont();
const titleLength = title.length;
const fontSize = titleLength < 35 ? 144 : titleLength < 55 ? 120 : titleLength < 75 ? 100 : 84;
return new ImageResponse(
(
<div
style={{
width: '2400px',
height: '1260px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '128px 144px',
backgroundColor: '#ffffff',
fontFamily: '"Bricolage Grotesque"',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Ghost P watermark */}
<div
style={{
position: 'absolute',
bottom: '-120px',
right: '-60px',
opacity: 0.035,
display: 'flex',
}}
>
<svg width="960" height="960" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#000000" />
</svg>
</div>
{/* Top row: logo + URL */}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<div style={{display: 'flex', alignItems: 'center', gap: '20px'}}>
<svg width="60" height="60" viewBox="0 0 1080 1080" fill="none">
<path d={PLUNK_P_PATH} fill="#171717" />
</svg>
<span
style={{
fontSize: '44px',
fontWeight: 800,
color: '#171717',
letterSpacing: '-0.03em',
}}
>
Plunk
</span>
</div>
<span style={{fontSize: '30px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
useplunk.com
</span>
</div>
{/* Main content */}
<div style={{display: 'flex', flexDirection: 'column', gap: '36px', maxWidth: '1840px'}}>
{tag ? (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 28px',
backgroundColor: '#f4f4f5',
borderRadius: '12px',
fontSize: '30px',
color: '#52525b',
fontWeight: 600,
width: 'fit-content',
letterSpacing: '-0.01em',
}}
>
{tag}
</div>
) : null}
<div
style={{
fontSize: `${fontSize}px`,
fontWeight: 800,
color: '#0a0a0a',
lineHeight: 1.08,
letterSpacing: '-0.04em',
whiteSpace: 'pre-wrap',
}}
>
{title}
</div>
{description ? (
<div
style={{
fontSize: '44px',
color: '#737373',
lineHeight: 1.5,
fontWeight: 400,
letterSpacing: '-0.01em',
}}
>
{description}
</div>
) : null}
</div>
{/* Bottom spacer */}
<div style={{display: 'flex'}} />
</div>
),
{
width: 2400,
height: 1260,
fonts: [
{
name: 'Bricolage Grotesque',
data: fontData,
style: 'normal',
weight: 800,
},
],
},
);
}
+282 -256
View File
@@ -8,36 +8,34 @@ import Head from 'next/head';
const features = [
{
icon: <Type className="h-5 w-5" />,
icon: <Type className="h-6 w-6" strokeWidth={1.5} />,
title: 'Visual WYSIWYG Editor',
description:
'Rich text editing with formatting toolbar. Bold, italic, headings, lists, links, images, and tables. No code required.',
featured: true,
},
{
icon: <Code2 className="h-5 w-5" />,
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
title: 'Full HTML Editor',
description:
'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
description: 'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
},
{
icon: <Zap className="h-5 w-5" />,
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
title: 'Smart Mode Switching',
description:
'Automatically detects complex HTML and switches to code mode. Warns you before changes that would lose custom formatting.',
description: 'Automatically detects complex HTML and switches to code mode. Warns you before changes that would lose custom formatting.',
},
{
icon: <Sparkles className="h-5 w-5" />,
icon: <Sparkles className="h-6 w-6" strokeWidth={1.5} />,
title: 'Powerful Variables',
description:
'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
description: 'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
},
{
icon: <Eye className="h-5 w-5" />,
icon: <Eye className="h-6 w-6" strokeWidth={1.5} />,
title: 'Live Preview',
description: 'Preview with real contact data. Test on desktop, tablet, and mobile views before sending.',
},
{
icon: <Palette className="h-5 w-5" />,
icon: <Palette className="h-6 w-6" strokeWidth={1.5} />,
title: 'Email-Safe HTML',
description: 'Automatic CSS inlining and email-client-friendly code generation. Yes, even in Outlook.',
},
@@ -45,21 +43,21 @@ const features = [
const useCases = [
{
icon: <Code2 className="h-6 w-6" />,
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
title: 'For Developers',
description:
'Full HTML control when you need it. Powerful variable system with autocomplete and fallbacks. Use templates in API calls, workflows, and campaigns.',
example: 'Password resets → API-triggered alerts → Webhook notifications → Those cat meme attachments',
example: 'Password resets → API-triggered alerts → Webhook notifications',
},
{
icon: <Palette className="h-6 w-6" />,
icon: <Palette className="h-6 w-6" strokeWidth={1.5} />,
title: 'For Marketers',
description:
'Visual editor for quick changes. Live preview with real customer data. Create professional emails without waiting for developers.',
example: 'Product announcements → Newsletter campaigns → Promotional emails → Customer onboarding',
},
{
icon: <Zap className="h-6 w-6" />,
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
title: 'For Teams',
description:
'One tool for everyone. Developers can code, marketers can design, everyone can preview. Reusable templates across campaigns and workflows.',
@@ -67,9 +65,6 @@ const useCases = [
},
];
/**
*
*/
export default function EmailEditorFeature() {
return (
<>
@@ -79,277 +74,308 @@ export default function EmailEditorFeature() {
name="description"
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
/>
<meta
property="og:title"
content="Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk"
/>
<meta property="og:title" content="Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk" />
<meta
property="og:description"
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
</Head>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Mail className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Email Editor & Templates</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
The Email Editor
<br />
That Speaks Both Languages
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Switch seamlessly between visual and code editing. Preview with real customer data. Create templates that
work everywhere.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Email Editor & Templates</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<span className={'flex items-center gap-2'}>
Try the editor free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
The editor that
<br />
speaks both languages.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Switch seamlessly between visual and code editing. Preview with real customer data. Create templates that
work everywhere.
</p>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Two editors, one experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Visual editing for speed, code editing for control</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
Try the editor free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
From first draft to send
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Create, preview, and deploy templates in minutes</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
1
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Create your template</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Use the visual editor for quick formatting or write custom HTML. Add variables with autocomplete.
</p>
</motion.div>
Two editors, one experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Visual editing for speed, code editing for control</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Preview with real data</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Select any contact and see exactly what they'll receive. Test on desktop, tablet, and mobile. No
surprises.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Use everywhere</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Use your template in campaigns, workflows, and API calls. One template, unlimited uses.
</p>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Built for every team</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Whether you're a developer, marketer, or founder</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
<p className={'text-sm text-neutral-700'}>{useCase.example}</p>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
</div>
</div>
</motion.div>
))}
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Build your first template today
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
From first draft to send
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Create, preview, and deploy templates in minutes</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Create your template',
body: 'Use the visual editor for quick formatting or write custom HTML. Add variables with autocomplete.',
},
{
step: '02',
title: 'Preview with real data',
body: 'Select any contact and see exactly what they\'ll receive. Test on desktop, tablet, and mobile.',
},
{
step: '03',
title: 'Use everywhere',
body: 'Use your template in campaigns, workflows, and API calls. One template, unlimited uses.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Built for every team
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Whether you&apos;re a developer, marketer, or founder</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.example}
</p>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Build your first template today.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
<Footer />
+273 -315
View File
@@ -8,33 +8,33 @@ import Head from 'next/head';
const features = [
{
icon: <Database className="h-5 w-5" />,
icon: <Database className="h-6 w-6" strokeWidth={1.5} />,
title: 'Automatic Contact Capture',
description: 'Every sender is automatically added to your contact database with no manual data entry required.',
featured: true,
},
{
icon: <Zap className="h-5 w-5" />,
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
title: 'Workflow Automation',
description: 'Trigger automated workflows when emails are received to create sophisticated two-way communication.',
},
{
icon: <Shield className="h-5 w-5" />,
icon: <Shield className="h-6 w-6" strokeWidth={1.5} />,
title: 'Built-in Security',
description:
'Spam, virus, SPF, DKIM, and DMARC filtering keeps your inbox clean. The spam stays out, the good stuff gets in.',
description: 'Spam, virus, SPF, DKIM, and DMARC filtering keeps your inbox clean. The spam stays out, the good stuff gets in.',
},
{
icon: <Bell className="h-5 w-5" />,
icon: <Bell className="h-6 w-6" strokeWidth={1.5} />,
title: 'Webhook Notifications',
description: 'Get instant notifications with rich metadata whenever an email arrives at your domain.',
},
{
icon: <Mail className="h-5 w-5" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Simple DNS Setup',
description: 'Add one MX record to your domain and start receiving emails immediately. No PhD required.',
},
{
icon: <Inbox className="h-5 w-5" />,
icon: <Inbox className="h-6 w-6" strokeWidth={1.5} />,
title: 'Real-Time Processing',
description: 'Emails are processed instantly and can trigger workflows or webhooks in real-time.',
},
@@ -42,21 +42,21 @@ const features = [
const useCases = [
{
icon: <Mail className="h-6 w-6" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Support Ticket Creation',
description:
'Automatically create support tickets when customers email support@yourdomain.com. Send auto-replies and route to your help desk system via webhooks. Your support team will thank you.',
benefits: ['Instant acknowledgment', 'Automatic ticket creation', 'No emails missed'],
},
{
icon: <Database className="h-6 w-6" />,
icon: <Database className="h-6 w-6" strokeWidth={1.5} />,
title: 'Lead Capture from Email',
description:
'Receive emails at info@yourdomain.com and automatically add senders to your CRM. Trigger nurture workflows based on when they reached out.',
benefits: ['Zero-friction lead capture', 'Auto-segmentation', 'Instant follow-up'],
},
{
icon: <Zap className="h-6 w-6" />,
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
title: 'Two-Way Conversations',
description:
'Let customers reply to your campaign emails and automatically trigger engagement workflows. Tag contacts as "engaged" when they respond.',
@@ -64,9 +64,6 @@ const useCases = [
},
];
/**
*
*/
export default function InboundEmailFeature() {
return (
<>
@@ -81,347 +78,308 @@ export default function InboundEmailFeature() {
property="og:description"
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
</Head>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Inbox className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Inbound Email</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Turn Incoming Emails
<br />
into Actions
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Receive emails at your custom domain and automatically trigger workflows, capture leads, or create support
tickets. Two-way email communication made simple.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Inbound Email</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<span className={'flex items-center gap-2'}>
Start receiving emails
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
Emails in,
<br />
actions out.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Receive emails at your custom domain and automatically trigger workflows, capture leads, or create support
tickets. Two-way email communication made simple.
</p>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Complete inbound email solution
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Everything you need to receive and process incoming emails
</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
Start receiving emails
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Set up in minutes</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Get started with inbound email in three simple steps</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
Complete inbound email solution
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to receive and process incoming emails</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
1
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Verify your domain</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Add and verify your custom domain in Plunk by configuring DKIM and SPF records in your DNS settings.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
className={
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
}
>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Add MX record</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Add one MX record to your DNS to route incoming emails to Plunk. Copy the record from your dashboard.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
className={
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
}
>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Start receiving</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Emails sent to any address at your domain are automatically received and can trigger workflows or
webhooks.
</p>
</motion.div>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Powerful use cases</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
From support to sales, inbound email unlocks new automation possibilities
</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
Set up in minutes
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>One MX record is all it takes</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Verify your domain',
body: 'Add and verify your custom domain in Plunk by configuring DKIM and SPF records in your DNS settings.',
},
{
step: '02',
title: 'Add MX record',
body: 'Add one MX record to your DNS to route incoming emails to Plunk. Copy the record directly from your dashboard.',
},
{
step: '03',
title: 'Start receiving',
body: 'Emails sent to any address at your domain are automatically received and can trigger workflows or webhooks.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 flex flex-wrap gap-2'}>
{useCase.benefits.map(benefit => (
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Powerful use cases
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From support to sales, inbound email unlocks new automation possibilities</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<div className={'mt-5 flex flex-wrap gap-x-6 gap-y-1'}>
{useCase.benefits.map(b => (
<span
key={benefit}
className={'rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700'}
key={b}
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{benefit}
{b}
</span>
))}
</div>
</div>
</div>
</motion.div>
))}
</motion.li>
))}
</ul>
</div>
</section>
{/* Technical Details */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl'}
>
<div className={'rounded-2xl border border-neutral-200 bg-white p-8 sm:p-12'}>
<h2 className={'text-3xl font-bold tracking-tight text-neutral-900 text-balance'}>
What happens when an email arrives?
</h2>
<div className={'mt-10'}>
{[
{
title: 'Email arrives at your domain',
description: 'Your MX record routes the email to Plunk for processing',
},
{
title: 'Security checks pass',
description: 'Automatic validation of spam, virus, SPF, DKIM, and DMARC',
},
{
title: 'Contact is created or updated',
description: 'The sender is automatically added to your contact database',
},
{
title: 'Workflows trigger automatically',
description: 'Configured workflows start running based on the incoming email',
},
].map((step, i, arr) => (
<div key={step.title} className={'relative flex gap-6'}>
{/* Vertical connector */}
{i < arr.length - 1 && (
<div className={'absolute left-[1.125rem] top-10 bottom-0 w-px bg-neutral-200'} />
)}
<div className={'relative flex-shrink-0'}>
<div
className={
'flex h-9 w-9 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-sm font-bold text-neutral-900'
}
>
{i + 1}
</div>
</div>
<div className={i < arr.length - 1 ? 'pb-8' : ''}>
<p className={'font-semibold text-neutral-900'}>{step.title}</p>
<p className={'mt-1 text-sm text-neutral-600'}>{step.description}</p>
</div>
</div>
))}
</div>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Your domain can receive emails too.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</motion.div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Your domain can receive emails too
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Set up inbound email on any verified domain in minutes. Replies, support tickets, and webhooks, all from
one platform.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+335 -305
View File
@@ -8,37 +8,35 @@ import Head from 'next/head';
const features = [
{
icon: <Filter className="h-5 w-5" />,
icon: <Filter className="h-6 w-6" strokeWidth={1.5} />,
title: 'Dynamic Filtering',
description:
'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
description: 'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
featured: true,
},
{
icon: <TrendingUp className="h-5 w-5" />,
icon: <TrendingUp className="h-6 w-6" strokeWidth={1.5} />,
title: 'Real-Time Updates',
description: 'Dynamic segments automatically update as contact data changes, always keeping your audience current.',
},
{
icon: <GitBranch className="h-5 w-5" />,
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
title: 'Workflow Integration',
description:
'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
description: 'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
},
{
icon: <Target className="h-5 w-5" />,
icon: <Target className="h-6 w-6" strokeWidth={1.5} />,
title: 'Campaign Targeting',
description:
'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
description: 'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
},
{
icon: <Users className="h-5 w-5" />,
icon: <Users className="h-6 w-6" strokeWidth={1.5} />,
title: 'Static Segments',
description: 'Manually curate contact lists for special groups like beta testers or VIP customers.',
},
{
icon: <Mail className="h-5 w-5" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Behavior-Based',
description: 'Segment by email engagement - who opened, clicked, bounced, or never received your emails.',
description: 'Segment by email engagement who opened, clicked, bounced, or never received your emails.',
},
];
@@ -50,7 +48,7 @@ const filterExamples = [
},
{
title: 'Re-engagement Needed',
description: 'Find inactive users who need a nudge to come back. Sometimes they just need a reminder.',
description: 'Find inactive users who need a nudge to come back',
filters: ['Last activity older than 60 days', 'Email sent but not opened', 'Subscribed equals true'],
},
{
@@ -62,28 +60,25 @@ const filterExamples = [
const useCases = [
{
icon: <Mail className="h-6 w-6" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Targeted Campaigns',
description:
'Send newsletters and announcements to specific audience segments instead of blasting everyone. Increase open rates by sending relevant content to the right people. Your unsubscribe rate will thank you.',
'Send newsletters and announcements to specific audience segments instead of blasting everyone. Increase open rates by sending relevant content to the right people.',
},
{
icon: <GitBranch className="h-6 w-6" />,
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
title: 'Behavior-Based Workflows',
description:
'Trigger workflows when contacts enter segments like "VIP Customers" or "Churning Users". Create personalized automations based on segment membership.',
},
{
icon: <Target className="h-6 w-6" />,
icon: <Target className="h-6 w-6" strokeWidth={1.5} />,
title: 'A/B Testing',
description:
'Create segments for test groups and control groups. Send different campaigns to each segment and measure results.',
},
];
/**
*
*/
export default function SegmentsFeature() {
return (
<>
@@ -96,320 +91,355 @@ export default function SegmentsFeature() {
<meta property="og:title" content="Audience Segmentation - Smart Contact Organization | Plunk" />
<meta
property="og:description"
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement. Target campaigns and trigger workflows based on segment membership."
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
</Head>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Users className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Audience Segmentation</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Target the Right Audience,
<br />
Every Time
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Organize contacts into dynamic segments based on behavior, attributes, and engagement. Send targeted
campaigns and trigger personalized workflows.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Audience Segmentation</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<span className={'flex items-center gap-2'}>
Start segmenting
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
Target the right audience,
<br />
every time.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Organize contacts into dynamic segments based on behavior, attributes, and engagement. Send targeted
campaigns and trigger personalized workflows.
</p>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
The right message to the right person
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build precise audiences and send campaigns that land</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
Start segmenting
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* Filter Examples */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Flexible filtering options
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build complex segments with nested AND/OR logic</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-6'}>
{filterExamples.map((example, index) => (
<motion.div
key={example.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<div className={'flex items-start justify-between gap-6'}>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{example.title}</h3>
<p className={'mt-2 text-neutral-600'}>{example.description}</p>
</div>
</div>
<div className={'mt-6 space-y-2'}>
{example.filters.map((filter, filterIndex) => (
<div key={filterIndex} className={'flex items-center gap-3 rounded-lg bg-neutral-50 px-4 py-3'}>
<Filter className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<span className={'font-mono text-sm text-neutral-700'}>{filter}</span>
</div>
))}
</div>
</motion.div>
))}
</div>
</section>
The right message to the right person
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build precise audiences and send campaigns that land</p>
</motion.div>
{/* Types of Segments */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Two types of segments</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Choose between dynamic filtering or manual curation</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
<TrendingUp className="h-6 w-6" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segments</h3>
<p className={'mt-2 text-neutral-600'}>
Automatically update based on filter conditions. As contact data changes, segment membership updates
in real-time.
</p>
<div className={'mt-6 space-y-2'}>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Filter-based membership</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Automatic updates</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Optional entry/exit tracking</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
<Users className="h-6 w-6" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Static Segments</h3>
<p className={'mt-2 text-neutral-600'}>
Manually curate your segment by adding specific contacts. Membership stays fixed until you change it.
</p>
<div className={'mt-6 space-y-2'}>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Manual contact selection</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Fixed membership</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Perfect for VIP lists</span>
</div>
</div>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Use segments everywhere
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From targeted campaigns to automated workflows</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
</div>
</div>
</motion.div>
))}
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Stop sending the same email to everyone
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Build precise audience segments and watch your open rates climb. 1,000 emails free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
{/* Filter examples */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
Flexible filtering options
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build complex segments with nested AND/OR logic</p>
</motion.div>
<div className={'space-y-5'}>
{filterExamples.map((example, index) => (
<motion.div
key={example.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-[24px] border border-neutral-200 bg-white p-8'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{example.title}
</h3>
<p className={'mt-1 text-neutral-600'}>{example.description}</p>
<div className={'mt-6 space-y-2'}>
{example.filters.map((filter, filterIndex) => (
<div key={filterIndex} className={'flex items-center gap-3 rounded-xl border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<Filter className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-sm text-neutral-700'}>
{filter}
</span>
</div>
))}
</div>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
{/* Segment types */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Two types of segments
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose between dynamic filtering or manual curation</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2'}>
{[
{
icon: <TrendingUp className="h-6 w-6" strokeWidth={1.5} />,
title: 'Dynamic Segments',
description: 'Automatically update based on filter conditions. As contact data changes, segment membership updates in real-time.',
bullets: ['Filter-based membership', 'Automatic updates', 'Optional entry/exit tracking'],
},
{
icon: <Users className="h-6 w-6" strokeWidth={1.5} />,
title: 'Static Segments',
description: 'Manually curate your segment by adding specific contacts. Membership stays fixed until you change it.',
bullets: ['Manual contact selection', 'Fixed membership', 'Perfect for VIP lists'],
},
].map((type, i) => (
<motion.div
key={type.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-[24px] border border-neutral-200 bg-white p-8'}
>
<div className={'text-neutral-900'}>{type.icon}</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{type.title}
</h3>
<p className={'mt-2 text-neutral-600'}>{type.description}</p>
<ul className={'mt-6 space-y-2'}>
{type.bullets.map(b => (
<li key={b} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-900'} />
{b}
</li>
))}
</ul>
</motion.div>
))}
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Use segments everywhere
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From targeted campaigns to automated workflows</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<p className={'col-span-12 leading-relaxed text-neutral-600 sm:col-span-8'}>
{useCase.description}
</p>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Stop sending the same email to everyone.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Build precise audience segments and watch your open rates climb. 1,000 emails free, no credit card
required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
<Footer />
+255 -207
View File
@@ -8,34 +8,33 @@ import Head from 'next/head';
const features = [
{
icon: <Settings className="h-5 w-5" />,
icon: <Settings className="h-6 w-6" strokeWidth={1.5} />,
title: 'Simple Configuration',
description: 'Quick setup with your project credentials. Works with any email client or application.',
featured: true,
},
{
icon: <Lock className="h-5 w-5" />,
icon: <Lock className="h-6 w-6" strokeWidth={1.5} />,
title: 'Secure Connections',
description: 'TLS/SSL encryption on ports 465 and 587. Your emails are always transmitted securely.',
},
{
icon: <Mail className="h-5 w-5" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Universal Compatibility',
description:
'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application. Even that ancient email client from 2005.',
description: 'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application.',
},
{
icon: <Server className="h-5 w-5" />,
icon: <Server className="h-6 w-6" strokeWidth={1.5} />,
title: 'Domain Validation',
description: 'Automatic verification that your sender domain is verified before accepting emails.',
},
{
icon: <Shield className="h-5 w-5" />,
icon: <Shield className="h-6 w-6" strokeWidth={1.5} />,
title: 'Full Feature Support',
description:
'Attachments, custom headers, HTML emails, and multiple recipients. Send those cat memes with confidence.',
description: 'Attachments, custom headers, HTML emails, and multiple recipients.',
},
{
icon: <Code2 className="h-5 w-5" />,
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
title: 'Same Infrastructure',
description: 'SMTP emails use the same reliable delivery infrastructure as API emails with full tracking.',
},
@@ -54,21 +53,21 @@ const comparisonData = [
const useCases = [
{
icon: <Settings className="h-6 w-6" />,
icon: <Settings className="h-6 w-6" strokeWidth={1.5} />,
title: 'Legacy System Integration',
description:
'Already have applications using SMTP? No need to rewrite code. Just swap your SMTP credentials and keep everything else the same. Your PM will love you.',
'Already have applications using SMTP? No need to rewrite code. Just swap your SMTP credentials and keep everything else the same.',
benefit: 'Zero code changes required',
},
{
icon: <Mail className="h-6 w-6" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Email Client Sending',
description:
'Marketing teams can send emails directly from Outlook, Thunderbird, or Apple Mail using familiar tools without learning new APIs.',
benefit: 'No technical knowledge needed',
},
{
icon: <Code2 className="h-6 w-6" />,
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
title: 'Framework Compatibility',
description:
'Works with any framework or language that supports SMTP. Perfect for older systems or platforms without HTTP API support.',
@@ -76,9 +75,6 @@ const useCases = [
},
];
/**
*
*/
export default function SMTPFeature() {
return (
<>
@@ -93,240 +89,292 @@ export default function SMTPFeature() {
property="og:description"
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
</Head>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Server className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>SMTP Email Sending</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Send Emails via SMTP or API
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Use our HTTP API for modern apps or drop in SMTP credentials for any legacy system. Same deliverability, same pricing, zero lock-in.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>SMTP</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<span className={'flex items-center gap-2'}>
Get SMTP credentials
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
Send via SMTP
<br />
or API. Your call.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Use our HTTP API for modern apps or drop in SMTP credentials for any legacy system. Same deliverability,
same pricing, zero lock-in.
</p>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP that works with everything</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Full authentication, tracking, and deliverability out of the box</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
Get SMTP credentials
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* Comparison Table */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP vs API</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose the right option for your use case</p>
</motion.div>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
SMTP that works with everything
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Full authentication, tracking, and deliverability out of the box
</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* Comparison table */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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-12'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
SMTP vs API
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose the right option for your use case</p>
</motion.div>
<div className={'overflow-hidden rounded-[24px] border border-neutral-200 bg-white'}>
<table className={'w-full'}>
<thead className={'bg-neutral-50'}>
<thead className={'border-b border-neutral-200 bg-neutral-50'}>
<tr>
<th className={'px-6 py-4 text-left text-sm font-semibold text-neutral-900'}>Feature</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>Traditional SMTP</th>
<th className={'bg-neutral-100 px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>
Plunk SMTP
</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>Plunk API</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-500'}>Traditional SMTP</th>
<th className={'bg-neutral-900 px-6 py-4 text-center text-sm font-semibold text-white'}>Plunk SMTP</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-500'}>Plunk API</th>
</tr>
</thead>
<tbody className={'divide-y divide-neutral-200'}>
<tbody className={'divide-y divide-neutral-100'}>
{comparisonData.map((row, index) => (
<tr key={index} className={'transition hover:bg-neutral-50'}>
<td className={'px-6 py-4 text-sm text-neutral-900'}>{row.feature}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.traditional}</td>
<td className={'bg-neutral-50 px-6 py-4 text-center text-sm font-medium text-neutral-900'}>
{row.plunkSMTP}
</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.plunkAPI}</td>
<td className={'px-6 py-4 text-sm font-medium text-neutral-900'}>{row.feature}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-500'}>{row.traditional}</td>
<td className={'bg-neutral-50 px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>{row.plunkSMTP}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-500'}>{row.plunkAPI}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className={'mt-6 rounded-lg bg-neutral-50 p-4 text-center'}>
<p className={'text-sm text-neutral-600'}>
<strong>Recommendation:</strong> Use API for modern applications with workflow automation. Use SMTP for
email clients and legacy systems.
</p>
</div>
<p className={'mt-4 text-sm text-neutral-500'}>
Recommendation: Use the API for modern applications with workflow automation. Use SMTP for email clients
and legacy systems.
</p>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>When to use SMTP</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Perfect for these scenarios</p>
</motion.div>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
When to use SMTP
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Perfect for these scenarios</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 inline-block text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.benefit}
</span>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
Start sending via SMTP today.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Get your SMTP credentials and start sending from any client or application. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2'}>
<div className={'h-2 w-2 rounded-full bg-green-500'} />
<span className={'text-sm font-medium text-neutral-700'}>{useCase.benefit}</span>
</div>
</div>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Start sending via SMTP</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Get your SMTP credentials and start sending emails from any client or application. No credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+282 -250
View File
@@ -8,32 +8,33 @@ import Head from 'next/head';
const features = [
{
icon: <Zap className="h-5 w-5" />,
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
title: 'Event-Driven Triggers',
description: 'Start workflows automatically when users sign up, make a purchase, or perform any custom action.',
featured: true,
},
{
icon: <Mail className="h-5 w-5" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Smart Email Sequences',
description: 'Send personalized emails at the right time with dynamic content based on user data.',
},
{
icon: <Clock className="h-5 w-5" />,
icon: <Clock className="h-6 w-6" strokeWidth={1.5} />,
title: 'Time-Based Delays',
description: 'Add strategic delays between steps to create perfectly timed email journeys. Patience is a virtue.',
},
{
icon: <GitBranch className="h-5 w-5" />,
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
title: 'Conditional Logic',
description: 'Branch workflows based on user behavior, attributes, or engagement to personalize every journey.',
},
{
icon: <Webhook className="h-5 w-5" />,
icon: <Webhook className="h-6 w-6" strokeWidth={1.5} />,
title: 'External Integrations',
description: 'Connect to external systems with webhooks to sync data or trigger actions outside of Plunk.',
},
{
icon: <RefreshCw className="h-5 w-5" />,
icon: <RefreshCw className="h-6 w-6" strokeWidth={1.5} />,
title: 'Re-entry Control',
description: 'Decide whether contacts can enter workflows multiple times or just once. No spam, just strategy.',
},
@@ -41,21 +42,21 @@ const features = [
const useCases = [
{
icon: <UserPlus className="h-6 w-6" />,
icon: <UserPlus className="h-6 w-6" strokeWidth={1.5} />,
title: 'User Onboarding',
description:
'Welcome new users with a personalized email series that guides them through your product features and helps them get started.',
example: 'Trigger on signup → Send welcome email → Wait 2 days → Send getting started tips',
},
{
icon: <Mail className="h-6 w-6" />,
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
title: 'Abandoned Cart Recovery',
description:
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives. Those forgotten items need a gentle nudge.',
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives.',
example: 'Trigger on cart abandoned → Wait 1 hour → Send reminder → Wait 1 day → Send discount offer',
},
{
icon: <RefreshCw className="h-6 w-6" />,
icon: <RefreshCw className="h-6 w-6" strokeWidth={1.5} />,
title: 'Re-engagement Campaigns',
description:
'Win back inactive users with targeted campaigns based on their last activity and engagement patterns.',
@@ -63,9 +64,6 @@ const useCases = [
},
];
/**
*
*/
export default function WorkflowsFeature() {
return (
<>
@@ -80,273 +78,307 @@ export default function WorkflowsFeature() {
property="og:description"
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
/>
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
</Head>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Zap className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Workflow Automation</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Email Automation
<br />
That Actually Works
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Turn events into personalized email journeys. Build sophisticated automation workflows with our visual
no-code builder.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Workflow Automation</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<span className={'flex items-center gap-2'}>
Start building workflows
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
Email automation
<br />
that actually works.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Turn events into personalized email journeys. Build sophisticated automation workflows with our visual
no-code builder.
</p>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Everything you need for email automation
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Powerful features that make complex automations simple</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
Start building workflows
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Visual workflow builder
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Create complex email automations without writing a single line of code
</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'
}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
1
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Choose a trigger</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Select an event that starts your workflow, like user signup, purchase, or any custom action you track.
</p>
</motion.div>
Everything you need for email automation
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Powerful features that make complex automations simple</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Build your flow</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Drag and drop steps to create your workflow. Add emails, delays, conditions, webhooks, and more.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Activate and monitor</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Enable your workflow and watch it run automatically. Monitor executions in real-time with full
visibility.
</p>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Built for every use case
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From onboarding to re-engagement, workflows handle it all</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
<p className={'font-mono text-sm text-neutral-700'}>{useCase.example}</p>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
</div>
</div>
</motion.div>
))}
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Set up your first workflow in minutes
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
Visual workflow builder
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Create complex email automations without writing a single line of code
</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Choose a trigger',
body: 'Select an event that starts your workflow, like user signup, purchase, or any custom action you track.',
},
{
step: '02',
title: 'Build your flow',
body: 'Drag and drop steps to create your workflow. Add emails, delays, conditions, webhooks, and more.',
},
{
step: '03',
title: 'Activate and monitor',
body: 'Enable your workflow and watch it run automatically. Monitor executions in real-time with full visibility.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm: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'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Built for every use case
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From onboarding to re-engagement, workflows handle it all</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.example}
</p>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Set up your first workflow in minutes.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
<Footer />
@@ -6,8 +6,8 @@ import Link from 'next/link';
export default function EmailAPIGuide() {
return (
<GuideLayout
title="Email API Guide: Everything You Need to Know with Code Examples"
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
title="What is an Email API? Complete Guide with Code Examples | Plunk"
description="An email API lets you send, receive, and manage emails programmatically. Learn how they work, see code examples in Node.js, Python, PHP, Ruby, and Go, and choose the right provider."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-api-guide"
@@ -78,7 +78,7 @@ export default function EmailAPIGuide() {
<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">
<div className="">
<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
@@ -86,14 +86,14 @@ export default function EmailAPIGuide() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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">
<div className="">
<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
@@ -101,14 +101,14 @@ export default function EmailAPIGuide() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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">
<div className="">
<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
@@ -535,7 +535,7 @@ func main() {
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -543,7 +543,7 @@ func main() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -551,7 +551,7 @@ func main() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -559,7 +559,7 @@ func main() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -567,7 +567,7 @@ func main() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -575,7 +575,7 @@ func main() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -2,6 +2,35 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is a good email bounce rate?',
answer:
'A bounce rate below 2% is considered excellent. Between 25% is concerning and needs attention. Above 5% is critical and will seriously damage your sender reputation and deliverability. For hard bounces specifically, keep this below 0.5% per campaign. Most reputable email platforms will suspend accounts or pause sending if hard bounce rates exceed 510%.',
},
{
question: 'What is the difference between a hard bounce and a soft bounce?',
answer:
'A hard bounce is a permanent delivery failure—the email address does not exist, the domain is invalid, or the receiving server has permanently blocked delivery. Remove hard bounce addresses immediately and never send to them again. A soft bounce is a temporary failure—the mailbox is full, the server is temporarily down, or the email is too large. Email platforms typically retry soft bounces automatically for 2472 hours.',
},
{
question: 'How do I reduce email bounce rates?',
answer:
'Key strategies to reduce bounces: (1) Use double opt-in to confirm email addresses at signup, (2) Validate email addresses in real-time on forms to catch typos and invalid formats, (3) Remove hard bounces immediately and automatically, (4) Clean your list every 612 months to remove stale addresses (1520% of email addresses go invalid per year), (5) Never purchase email lists—they have 2040% invalid addresses, and (6) Maintain consistent sending to prevent addresses going stale.',
},
{
question: 'Do bounces affect my email deliverability?',
answer:
'Yes, high bounce rates directly damage your sender reputation. Email providers like Gmail and Outlook track bounce rates as a signal of list quality and sending practices. High bounce rates cause more of your emails—even to valid addresses—to land in spam. Persistent high bounce rates can lead to your sending IP being blacklisted, making it extremely difficult to deliver any emails.',
},
{
question: 'Why are my emails bouncing suddenly?',
answer:
'Sudden increases in bounce rates can be caused by: sending to an old list that has gone stale (addresses expire over time), sending to a purchased list, a domain or server configuration change, your sending IP or domain being blacklisted, or reaching inbox provider sending limits. Identify the pattern—are bounces happening on specific domains or across all recipients?—then investigate the root cause.',
},
];
export default function EmailBounceRate() {
return (
@@ -11,6 +40,7 @@ export default function EmailBounceRate() {
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/email-bounce-rate"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -141,7 +141,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
<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">
<div className="">
<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
@@ -149,7 +149,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -157,7 +157,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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 propositions—exclusive content, limited offers,
@@ -165,7 +165,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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%
@@ -173,7 +173,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -181,7 +181,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -402,14 +402,14 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
<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">
<div className="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">
<div className="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
@@ -417,7 +417,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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
@@ -425,14 +425,14 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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">
<div className="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
@@ -71,7 +71,7 @@ export default function EmailDeliverability() {
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -79,7 +79,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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,
@@ -87,7 +87,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -95,7 +95,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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.
@@ -103,7 +103,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -107,7 +107,7 @@ export default function EmailMarketingBestPractices() {
<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">
<div className="">
<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,
@@ -125,7 +125,7 @@ export default function EmailMarketingBestPractices() {
</div>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -133,7 +133,7 @@ export default function EmailMarketingBestPractices() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -141,7 +141,7 @@ export default function EmailMarketingBestPractices() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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">
@@ -153,7 +153,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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">
@@ -172,7 +172,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -361,7 +361,7 @@ export default function EmailMarketingBestPractices() {
</p>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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>
@@ -385,7 +385,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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>
+120 -10
View File
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is a good email open rate?',
answer:
'A good email open rate depends on your industry and email type. For B2B emails, 25-35% is considered excellent. For B2C marketing emails, 20-30% is strong. Overall industry averages range from 20-30%, though Apple Mail Privacy Protection has inflated reported rates by 5-15 percentage points since 2021. Compare against your own historical data rather than industry averages alone.',
},
{
question: 'What are average B2B email open rates?',
answer:
'Average B2B email open rates typically range from 20-30%, with top-performing campaigns achieving 35-45%. B2B emails tend to outperform B2C because recipients opted in for professional reasons and have higher intent. Industry-specific rates vary: SaaS averages 22-28%, financial services 20-25%, and professional services 25-30%. Transactional B2B emails like invoices and notifications often exceed 50%.',
},
{
question: 'How do I improve my email open rate?',
answer:
'The most impactful ways to improve email open rates are: (1) Write compelling, specific subject lines under 50 characters, (2) Personalize beyond first names—use behavioral data and purchase history, (3) Send at optimal times (TuesdayThursday, 10am2pm for B2B), (4) Segment your list to send relevant content to specific groups, (5) Keep your list clean by removing inactive subscribers, (6) Use a recognizable sender name, and (7) Optimize preview text to complement the subject line.',
},
{
question: 'Why are my email open rates low?',
answer:
'Low email open rates typically result from: weak subject lines that fail to create curiosity or urgency, poor list hygiene with too many inactive subscribers dragging down averages, emails landing in spam due to deliverability or authentication issues, bad send timing, email fatigue from sending too frequently, or poor segmentation sending irrelevant content. Review each factor systematically and A/B test changes one at a time.',
},
{
question: 'Does Apple Mail Privacy Protection affect open rates?',
answer:
'Yes. Apple Mail Privacy Protection (MPP), launched in iOS 15 in 2021, pre-loads email images and tracking pixels, registering "opens" even if the user never actually read the email. This inflates open rates by 5-15 percentage points across the industry. To adapt, focus on click-through rates and conversions as more reliable engagement metrics, and segment by email client to understand true engagement patterns.',
},
];
export default function EmailOpenRate() {
return (
<GuideLayout
title="Email Open Rate: Benchmarks, Strategies & Best Practices"
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
title="Email Open Rates: Industry Benchmarks & How to Improve Yours"
description="Learn what affects email open rates, industry benchmarks by sector, B2B vs B2C averages, and proven tactics to improve your open rates. Complete guide with actionable tips."
lastUpdated="2025-12-20"
readTime="10 min"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-open-rate"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -126,12 +156,92 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</InfoBox>
</section>
{/* B2B Email Open Rates */}
<section id="b2b-email-open-rates" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">B2B Email Open Rates</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
B2B (business-to-business) emails consistently outperform B2C benchmarks. Recipients opted in because of
genuine professional need, making them more engaged audiences.
</p>
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
<table className="w-full">
<thead>
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold">B2B Industry</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Average Open Rate</th>
<th className="px-6 py-4 text-left text-sm font-semibold">Top Performers</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">SaaS / Technology</td>
<td className="px-6 py-4 text-sm text-neutral-700">2228%</td>
<td className="px-6 py-4 text-sm text-neutral-700">35%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Professional Services</td>
<td className="px-6 py-4 text-sm text-neutral-700">2532%</td>
<td className="px-6 py-4 text-sm text-neutral-700">40%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Financial Services</td>
<td className="px-6 py-4 text-sm text-neutral-700">2026%</td>
<td className="px-6 py-4 text-sm text-neutral-700">33%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Agency / Marketing</td>
<td className="px-6 py-4 text-sm text-neutral-700">1824%</td>
<td className="px-6 py-4 text-sm text-neutral-700">30%+</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm text-neutral-900">Transactional / Receipts</td>
<td className="px-6 py-4 text-sm text-neutral-700">4565%</td>
<td className="px-6 py-4 text-sm text-neutral-700">70%+</td>
</tr>
</tbody>
</table>
</div>
<div className="grid gap-6 md:grid-cols-2 mb-6">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B-Specific Tactics</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li>• Send TuesdayThursday, 10am2pm in recipient's timezone</li>
<li> Use company/product name in subject line for recognition</li>
<li> Personalize with job title, industry, or company size</li>
<li> Reference specific pain points relevant to their role</li>
<li> Keep frequency lower12 times per month for cold lists</li>
</ul>
</div>
<div className="rounded-xl border border-neutral-200 bg-white p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B vs B2C Differences</h3>
<ul className="space-y-2 text-sm text-neutral-700">
<li> B2B decisions are more consideredlonger subject lines acceptable</li>
<li> B2B audiences check email less frequently on weekends</li>
<li> B2B recipients share inboxes with colleagues, affecting metrics</li>
<li> B2B unsubscribe rates are typically lower (0.10.3% vs 0.30.5%)</li>
<li> B2B transactional emails (invoices, updates) have much higher opens</li>
</ul>
</div>
</div>
<InfoBox type="tip" title="Segment Transactional vs Marketing">
<p>
Keep transactional emails (invoices, notifications, password resets) in separate campaigns from marketing
emails. Transactional emails typically achieve 4060% open rates, which will skew your marketing
benchmarks if mixed together.
</p>
</InfoBox>
</section>
{/* Factors Affecting */}
<section id="factors" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
<div className="space-y-6">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -161,7 +271,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</div>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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 name—either your brand or a
@@ -169,7 +279,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -177,7 +287,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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.
@@ -185,7 +295,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -193,7 +303,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -201,7 +311,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -70,7 +70,7 @@ export default function EmailSenderReputation() {
<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">
<div className="">
<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
@@ -81,7 +81,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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.
@@ -92,7 +92,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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,
@@ -103,7 +103,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -114,7 +114,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -125,7 +125,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -136,7 +136,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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)
@@ -147,7 +147,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -158,7 +158,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
+153 -138
View File
@@ -10,7 +10,7 @@ interface Guide {
title: string;
description: string;
href: string;
icon: React.ComponentType<{className?: string}>;
icon: React.ComponentType<{className?: string; strokeWidth?: number}>;
badge?: string;
}
@@ -94,10 +94,13 @@ const guides: Guide[] = [
},
];
/**
* Email Guides hub page
*/
const badgeOrder = ['Authentication', 'Deliverability', 'Analytics', 'Technical', 'Best Practices', 'Fundamentals'];
export default function GuidesIndex() {
const categories = badgeOrder
.map(name => ({name, guides: guides.filter(g => g.badge === name)}))
.filter(c => c.guides.length > 0);
return (
<>
<NextSeo
@@ -109,160 +112,172 @@ export default function GuidesIndex() {
description:
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
url: 'https://www.useplunk.com/guides',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Guides+for+Developers&tag=Guide', alt: 'Plunk Guides', width: 1200, height: 630}],
}}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<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'
}
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<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`}
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Free Guides
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
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'
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
<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>
Master email,
<br />
start to finish.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Free guides on email authentication, deliverability, best practices, and technical implementation.
</p>
{/* 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]}}
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<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>
);
})}
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
</div>
</motion.div>
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
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'
}
>
Get started free
</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>
{/* Guides - Categorized */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<div className={'space-y-16'}>
{categories.map((category, catIndex) => (
<motion.div
key={category.name}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: catIndex * 0.06, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-6 flex items-center gap-4'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'shrink-0 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
{category.name}
</span>
<div className={'h-px flex-1 bg-neutral-200'} />
</div>
<div className={'divide-y divide-neutral-100'}>
{category.guides.map(guide => {
const Icon = guide.icon;
return (
<Link
key={guide.href}
href={guide.href}
className={
'group -mx-4 flex items-center gap-5 rounded-lg px-4 py-5 transition hover:bg-neutral-50 sm:gap-6'
}
>
<Icon
className={'h-5 w-5 shrink-0 text-neutral-400 transition group-hover:text-neutral-600'}
strokeWidth={1.5}
/>
<div className={'min-w-0 flex-1'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'font-bold tracking-[-0.01em] text-neutral-900'}
>
{guide.title}
</h3>
<p className={'mt-0.5 truncate text-sm text-neutral-500'}>{guide.description}</p>
</div>
<ArrowRight
className={
'h-4 w-4 shrink-0 text-neutral-300 transition-transform group-hover:translate-x-0.5 group-hover:text-neutral-600'
}
/>
</Link>
);
})}
</div>
</motion.div>
))}
</div>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Put it into practice.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Start free. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
<Footer />
+155 -12
View File
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is DKIM?',
answer:
'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a cryptographic digital signature to your outgoing emails. It allows receiving mail servers to verify that an email was genuinely sent from your domain and that its content was not altered in transit, improving deliverability and protecting against email spoofing.',
},
{
question: 'What is a DKIM key?',
answer:
'A DKIM key is a cryptographic key pair used to sign and verify emails. The private key is stored securely on your mail server and used to sign outgoing emails. The public key is published as a TXT record in your domain\'s DNS under selector._domainkey.yourdomain.com and is used by receiving servers to verify the signature. You should never share or expose your DKIM private key.',
},
{
question: 'How do I set up DKIM?',
answer:
'Setting up DKIM involves three steps: (1) Generate a DKIM key pair for your domain—most email providers do this automatically, (2) Publish the public key as a TXT record in your DNS at selector._domainkey.yourdomain.com, and (3) Configure your mail server or email service provider to sign outgoing emails with the private key. Platforms like Plunk handle all of this automatically when you add your domain.',
},
{
question: 'What is the difference between DKIM and SPF?',
answer:
'SPF (Sender Policy Framework) verifies that the sending mail server is authorized to send email for your domain by checking its IP address against your DNS record. DKIM adds a cryptographic signature to the email that verifies the content has not been altered in transit. They are complementary: SPF validates the server identity, DKIM validates the message integrity. Using both together—along with DMARC—provides the strongest email authentication.',
},
{
question: 'What happens if DKIM fails?',
answer:
'If DKIM verification fails, the receiving mail server may treat the email as suspicious. Depending on your DMARC policy, failed DKIM emails might be delivered normally (p=none), sent to spam (p=quarantine), or completely rejected (p=reject). Persistent DKIM failures damage your sender reputation and reduce email deliverability. Common causes include misconfigured DNS records, expired keys, or email modification during forwarding.',
},
];
export default function WhatIsDKIM() {
return (
<GuideLayout
title="What is DKIM? Email Authentication Explained"
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
description="Learn how DKIM (DomainKeys Identified Mail) works, what a DKIM key is, and how to set it up to protect your emails from spoofing and improve deliverability."
lastUpdated="2025-12-20"
readTime="8 min"
readTime="10 min"
canonical="https://www.useplunk.com/guides/what-is-dkim"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -33,7 +63,7 @@ export default function WhatIsDKIM() {
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -41,7 +71,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -49,7 +79,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -57,7 +87,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -252,7 +282,7 @@ export default function WhatIsDKIM() {
<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">
<div className="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
@@ -260,7 +290,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<div className="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
@@ -268,7 +298,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<div className="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
@@ -276,7 +306,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-green-500 pl-6 py-2">
<div className="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
@@ -284,14 +314,14 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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">
<div className="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.
@@ -300,6 +330,119 @@ export default function WhatIsDKIM() {
</div>
</section>
{/* DKIM vs SPF */}
<section id="dkim-vs-spf" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM vs SPF: What's the Difference?</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
DKIM and SPF are both email authentication protocols, but they work differently and check different things:
</p>
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
<table className="w-full">
<thead className="bg-neutral-50">
<tr>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">Feature</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">DKIM</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">SPF</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 bg-white">
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">What it validates</td>
<td className="px-6 py-4 text-sm text-neutral-700">Email content integrity</td>
<td className="px-6 py-4 text-sm text-neutral-700">Sending server authorization</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">How it works</td>
<td className="px-6 py-4 text-sm text-neutral-700">Cryptographic signature in headers</td>
<td className="px-6 py-4 text-sm text-neutral-700">IP address check against DNS list</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Survives forwarding</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (if content unchanged)</td>
<td className="px-6 py-4 text-sm text-neutral-700">No (forwarded IP changes)</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">DNS record type</td>
<td className="px-6 py-4 text-sm text-neutral-700">TXT at selector._domainkey.*</td>
<td className="px-6 py-4 text-sm text-neutral-700">TXT at root domain</td>
</tr>
<tr>
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Required for DMARC</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
</tr>
</tbody>
</table>
</div>
<InfoBox type="tip" title="Use Both for Maximum Protection">
<p>
DKIM and SPF complement each other. SPF covers scenarios where DKIM can't (like forged server IPs), and DKIM
covers forwarding scenarios where SPF breaks. Implementing bothplus DMARCgives you complete email
authentication coverage.
</p>
</InfoBox>
</section>
{/* How to Test DKIM */}
<section id="test-dkim" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Test Your DKIM Setup</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
After setting up DKIM, verify it's working correctly using these methods:
</p>
<div className="space-y-6">
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">1. Check the Email Headers</h3>
<p className="text-neutral-700 mb-4">
Send a test email and view the raw message headers. Look for the <code>DKIM-Signature</code> header and the{' '}
<code>Authentication-Results</code> header which shows whether DKIM passed or failed:
</p>
<CodeBlock
language="text"
title="Example Authentication-Results Header"
code={`Authentication-Results: mx.google.com;
dkim=pass header.i=@yourdomain.com header.s=default header.b=GJwP3Qr8;
spf=pass (google.com: domain of you@yourdomain.com designates 1.2.3.4 as permitted sender);
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=yourdomain.com`}
/>
</div>
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">2. Use DNS Lookup Tools</h3>
<p className="text-neutral-700">
Verify your DKIM public key is correctly published in DNS by querying your DKIM TXT record:
</p>
<CodeBlock
language="bash"
title="DNS Lookup Command"
code={`# Check your DKIM record via DNS
dig TXT default._domainkey.yourdomain.com
# Or using nslookup
nslookup -type=TXT default._domainkey.yourdomain.com`}
/>
</div>
<div className="">
<h3 className="text-xl font-semibold text-neutral-900 mb-3">3. Send to Gmail and Check</h3>
<p className="text-neutral-700">
Send a test email to a Gmail address, then click the three-dot menu and select "Show original". The
"Summary" at the top will show DKIM: PASS or DKIM: FAIL, confirming your setup is working.
</p>
</div>
</div>
<InfoBox type="info" title="Common DKIM Issues">
<p>
If DKIM fails, check that: (1) The DNS record uses the correct selector name, (2) The record hasn't been
truncated by your DNS provider (long keys may need to be split), (3) DNS propagation is complete (can take
up to 48 hours), and (4) Your email service is configured to sign with the correct private key.
</p>
</InfoBox>
</section>
{/* Related Guides */}
<section id="related-guides" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Authentication Guides</h2>
@@ -2,15 +2,45 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is DMARC?',
answer:
'DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an email authentication protocol that builds on SPF and DKIM to protect your domain from spoofing and phishing. It tells receiving mail servers what to do when an email fails authentication checks—none (monitor), quarantine (spam), or reject (block)—and provides detailed reports about who is sending email using your domain.',
},
{
question: 'What is a DMARC policy?',
answer:
'A DMARC policy (the "p=" tag) determines how receiving mail servers handle emails that fail authentication. p=none means monitor only and take no action; p=quarantine sends failed emails to spam; p=reject completely blocks failed emails. Start with p=none to gather data, then progressively tighten to quarantine and reject as you confirm all your legitimate senders are properly authenticated.',
},
{
question: 'What does p=reject mean in DMARC?',
answer:
'p=reject is the strictest DMARC policy. Emails that fail DMARC authentication are completely rejected and not delivered to the recipient. This provides maximum protection against phishing and spoofing, but requires that all your legitimate email sources (newsletters, CRMs, transactional tools) are properly configured with SPF and DKIM before enabling it.',
},
{
question: 'How do I add a DMARC record?',
answer:
'Add a TXT record in your DNS with the name _dmarc.yourdomain.com. Start with a monitoring-only value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com. This records email activity without affecting delivery. After reviewing reports for 2-4 weeks and confirming your SPF and DKIM setup, gradually strengthen to p=quarantine then p=reject.',
},
{
question: 'Is DMARC required for email deliverability?',
answer:
'Since February 2024, Google and Yahoo require DMARC authentication (with at least p=none) for bulk senders sending more than 5,000 emails per day to Gmail or Yahoo addresses. While not strictly required for smaller senders, implementing DMARC is strongly recommended for all domains to improve deliverability, prevent phishing, and meet industry best practices.',
},
];
export default function WhatIsDMARC() {
return (
<GuideLayout
title="What is DMARC? Email Policy & Reporting Explained"
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
title="What is DMARC? Policy, Setup & Reporting Explained"
description="Learn how DMARC works with SPF and DKIM to protect your domain. Understand DMARC policies (none, quarantine, reject), how to set up a DMARC record, and read reports."
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/what-is-dmarc"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -32,7 +62,7 @@ export default function WhatIsDMARC() {
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -40,7 +70,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -48,7 +78,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -56,7 +86,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
+38 -8
View File
@@ -2,6 +2,35 @@ import React from 'react';
import {GuideLayout, InfoBox} from '../../components/guides';
import {CodeBlock} from '../../components/CodeBlock';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const faqs: FAQ[] = [
{
question: 'What is SPF in email?',
answer:
'SPF (Sender Policy Framework) is an email authentication protocol that lets domain owners specify which mail servers are authorized to send email on behalf of their domain. It works by publishing a list of authorized IP addresses in your DNS as a TXT record. When an email arrives, the receiving server checks if the sending server\'s IP address is on your approved list.',
},
{
question: 'What is the difference between SPF and DKIM?',
answer:
'SPF verifies that the sending mail server is authorized to send from your domain by checking the server\'s IP address against your DNS record. DKIM adds a cryptographic signature to the email body and headers, verifying the content was not modified in transit. SPF validates who is sending; DKIM validates what was sent. Both work best together, and DMARC requires at least one of them to be properly configured.',
},
{
question: 'How do I create an SPF record?',
answer:
'Add a TXT record to your domain\'s DNS at the root domain (@ or yourdomain.com) with the format: v=spf1 include:[your-email-service] ~all. Replace the include: with the SPF value from your email provider. If you use multiple email services, combine them in one record: v=spf1 include:_spf.google.com include:spf.useplunk.com ~all. You can only have one SPF record per domain.',
},
{
question: 'What does ~all mean in an SPF record?',
answer:
'~all (tilde-all) at the end of an SPF record is a "soft fail" qualifier, meaning emails from unlisted servers should be accepted but flagged as potentially suspicious. The alternative -all (hard fail) completely rejects emails from unlisted servers. Most experts recommend ~all for initial setup to avoid blocking legitimate emails. Never use +all—it passes all emails and completely defeats SPF\'s purpose.',
},
{
question: 'Why is SPF failing even though I set it up correctly?',
answer:
'Common SPF failure causes: (1) You have multiple SPF records—you can only have one, combine all senders into a single record, (2) You exceeded the 10 DNS lookup limit—each include: counts as one lookup, (3) You added a new email service but forgot to add its SPF include, (4) DNS propagation is still in progress—can take up to 48 hours, (5) Your email is being forwarded, which changes the sending IP and breaks SPF (use DKIM too to handle forwarding).',
},
];
export default function WhatIsSPF() {
return (
@@ -11,6 +40,7 @@ export default function WhatIsSPF() {
lastUpdated="2025-12-20"
readTime="7 min"
canonical="https://www.useplunk.com/guides/what-is-spf"
faqs={faqs}
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -32,7 +62,7 @@ export default function WhatIsSPF() {
</p>
<div className="space-y-6 mb-8">
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -40,7 +70,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -48,7 +78,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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
@@ -56,7 +86,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-neutral-900 pl-6">
<div className="">
<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,
@@ -268,7 +298,7 @@ export default function WhatIsSPF() {
<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">
<div className="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
@@ -276,7 +306,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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
@@ -284,7 +314,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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
@@ -292,7 +322,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="border-l-4 border-red-500 pl-6 py-2">
<div className="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
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+265 -194
View File
@@ -1,9 +1,21 @@
import {NextSeo} from 'next-seo';
import React from 'react';
import {Footer, Navbar} from '../components';
import {Footer, Navbar, SectionHeader} from '../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../lib/constants';
import {ArrowRight, BarChart3, Code2, Globe, Mail, PackageOpen, Shield, Users, Zap, X, Check} from 'lucide-react';
import {
ArrowRight,
BarChart3,
Code2,
Globe,
Mail,
PackageOpen,
Shield,
Users,
Zap,
X,
Check,
} from 'lucide-react';
import Link from 'next/link';
import {GithubIcon} from 'lucide-react';
@@ -55,9 +67,6 @@ const includedFeatures = [
},
];
/**
*
*/
export default function Pricing() {
return (
<>
@@ -69,232 +78,294 @@ export default function Pricing() {
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 1,000 emails/month across transactional, workflow, and campaign emails. 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. No hidden fees.',
images: [
{
url: 'https://www.useplunk.com/api/og?title=Simple%2C+Honest+Pricing&description=%240.001+per+email.+No+contact+limits.+Free+to+start.',
width: 1200,
height: 630,
alt: 'Plunk Pricing',
},
],
}}
additionalMetaTags={[
{
property: 'title',
content: 'Plunk Pricing | The Open-Source Email Platform',
},
]}
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
/>
<Navbar />
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative py-20 sm:py-32'}>
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#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%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl text-balance">
Simple, transparent pricing
</h1>
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">
1,000 emails free every month. Then $0.001 per email. Unlimited contacts, no hidden fees.
</p>
</motion.div>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Simple, transparent pricing
</h1>
<p className={'mx-auto mt-6 max-w-2xl text-xl text-neutral-600'}>
Free plan: 1,000 emails per month. Paid plan: $0.001 per email. Unlimited contacts, no hidden fees.
</p>
</motion.div>
</div>
</section>
{/* Pricing tiers */}
<section className={'pb-20'}>
<div className={'mx-auto grid max-w-4xl gap-px bg-neutral-200 sm:grid-cols-2'}>
{/* Free */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Free forever</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>1,000</span>
<span className={'text-lg text-neutral-500'}>emails / mo</span>
</div>
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
<ul className={'mt-8 flex-1 space-y-3'}>
{['Transactional emails', 'Workflow automation', 'Campaign broadcasts', 'Custom domains', 'Click & open tracking', 'Unlimited contacts'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
</li>
))}
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
<X className={'h-4 w-4 flex-shrink-0'} />
Plunk branding on emails
</li>
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'mt-10 block w-full rounded-lg border border-neutral-300 px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:border-neutral-400'}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<div className={'mx-auto grid max-w-4xl gap-px bg-neutral-200 sm:grid-cols-2'}>
{/* Free */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
Start for free
</motion.a>
</motion.div>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
>
Free forever
</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span
style={{fontFamily: 'var(--font-display)'}}
className={'text-6xl font-extrabold tracking-[-0.03em] text-neutral-900'}
>
1,000
</span>
<span className={'text-lg text-neutral-500'}>emails / mo</span>
</div>
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
{/* Pay as you grow */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Pay as you grow</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>$0.001</span>
<span className={'text-lg text-neutral-500'}>/ email</span>
</div>
<p className={'mt-2 text-sm'}>&nbsp;</p>
<ul className={'mt-8 flex-1 space-y-3'}>
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
<ul className={'mt-8 flex-1 space-y-3'}>
{[
'Transactional emails',
'Workflow automation',
'Campaign broadcasts',
'Custom domains',
'Click & open tracking',
'Unlimited contacts',
].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
</li>
))}
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
<X className={'h-4 w-4 flex-shrink-0'} />
Plunk branding on emails
</li>
))}
</ul>
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'mt-10 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800'}
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'mt-10 block w-full rounded-full border border-neutral-300 px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
Start for free
</motion.a>
</motion.div>
{/* Pay as you grow */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-neutral-900 p-10 text-white'}
>
Get started
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
>
Pay as you grow
</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span
style={{fontFamily: 'var(--font-display)'}}
className={'text-6xl font-extrabold tracking-[-0.03em] text-white'}
>
$0.001
</span>
<span className={'text-lg text-neutral-400'}>/ email</span>
</div>
<p className={'mt-2 text-sm'}>&nbsp;</p>
</motion.a>
</motion.div>
<ul className={'mt-8 flex-1 space-y-3'}>
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-300'}>
<Check className={'h-4 w-4 flex-shrink-0 text-white'} />
{item}
</li>
))}
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'mt-10 block w-full rounded-full bg-white px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Get started
</motion.a>
</motion.div>
</div>
</div>
</section>
{/* Every feature included */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Every feature on every plan
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>No feature tiers, no add-ons, no surprises</p>
</motion.div>
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<SectionHeader
number={'01'}
label={'Included'}
title={'Every feature, every plan.'}
subtitle={'No feature tiers, no add-ons, no surprises.'}
/>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{includedFeatures.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'}>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
<ul className={'mt-16 divide-y divide-neutral-200 border-y border-neutral-200'}>
{includedFeatures.map((feature, index) => (
<motion.li
key={feature.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-3 py-7 sm:grid-cols-[1fr_2fr] sm:items-baseline sm:gap-12 sm:py-8'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-base font-semibold text-neutral-900'}
>
{feature.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.li>
))}
</ul>
</div>
</section>
{/* Self-host */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}
>
<div className={'flex flex-col items-center gap-6 p-10 sm:flex-row sm:gap-0'}>
<div className={'flex-1 text-center sm:text-left'}>
<div className={'mb-3 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-3 py-1 text-sm font-medium text-neutral-700'}>
<PackageOpen className={'h-3.5 w-3.5'} />
Self-hostable
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'overflow-hidden rounded-[24px] border border-neutral-200 bg-white'}
>
<div className={'flex flex-col items-center gap-6 p-10 sm:flex-row sm:gap-0'}>
<div className={'flex-1 text-center sm:text-left'}>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-3 inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
<PackageOpen className={'h-3.5 w-3.5'} />
Self-hostable
</div>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-2xl font-bold tracking-[-0.025em] text-neutral-900'}
>
Run it on your own infrastructure
</h2>
<p className={'mt-2 text-neutral-600'}>
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose
in minutes.
</p>
</div>
<div className={'sm:ml-auto sm:pl-8'}>
<motion.button
onClick={() => window.open('https://github.com/useplunk/plunk', '_blank')}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'flex w-full items-center justify-center gap-x-3 rounded-full bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'
}
>
<GithubIcon size={18} />
View on GitHub
</motion.button>
</div>
<h2 className={'text-2xl font-bold text-neutral-900'}>Run it on your own infrastructure</h2>
<p className={'mt-2 text-neutral-600'}>
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose in minutes.
</p>
</div>
<div className={'sm:ml-auto sm:pl-8'}>
<motion.button
onClick={() => {
window.open('https://github.com/useplunk/plunk', '_blank');
}}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'flex w-full items-center justify-center gap-x-3 rounded-lg bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'}
>
<GithubIcon size={18} />
View on GitHub
</motion.button>
</div>
</div>
</motion.div>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Start sending in 5 minutes
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. No credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
<span className={'flex items-center gap-2'}>
Create free account
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'https://github.com/useplunk/plunk'}
target={'_blank'}
className={'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'}
Start sending in 5 minutes.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
Self-host for free
</Link>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Start free. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Create free account
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'https://github.com/useplunk/plunk'}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
}
>
Self-host for free
</Link>
</div>
</motion.div>
</div>
</motion.div>
</div>
</section>
</main>
<Footer />
@@ -0,0 +1,621 @@
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
import {AlertTriangle, ArrowRight, CheckCircle, Key, XCircle} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
const COMMON_SELECTORS = [
{label: 'Google Workspace', value: 'google'},
{label: 'Microsoft 365', value: 'selector1'},
{label: 'Microsoft 365 (2)', value: 'selector2'},
{label: 'Mailchimp', value: 'k1'},
{label: 'Mailchimp (2)', value: 'k2'},
{label: 'Postmark', value: 'pm'},
{label: 'SendGrid', value: 'sendgrid'},
{label: 'Amazon SES', value: 'ses'},
{label: 'Mailjet', value: 'mailjet'},
{label: 'Zoho', value: 'zoho'},
{label: 'Generic', value: 'mail'},
{label: 'Generic (2)', value: 'default'},
{label: 'Generic (3)', value: 's1'},
{label: 'Generic (4)', value: 's2'},
];
interface DkimIssue {
type: 'error' | 'warning' | 'pass';
label: string;
detail: string;
}
interface DkimAnalysis {
keyType: string;
isRevoked: boolean;
isTesting: boolean;
publicKeySnippet: string;
issues: DkimIssue[];
grade: 'pass' | 'warning' | 'fail';
}
function analyzeDkim(tags: Record<string, string>): DkimAnalysis {
const keyType = tags['k'] ?? 'rsa';
const publicKey = tags['p'] ?? '';
const flags = tags['t'] ?? '';
const isRevoked = publicKey === '';
const isTesting = flags.includes('y');
const issues: DkimIssue[] = [];
if (isRevoked) {
issues.push({type: 'error', label: 'DKIM key has been revoked', detail: 'p= is empty, which signals that this key has been intentionally revoked. Email signed with this selector will fail DKIM validation. Publish a new key.'});
} else {
issues.push({type: 'pass', label: 'Public key is present', detail: 'A valid public key is published for this selector.'});
}
if (isTesting) {
issues.push({type: 'warning', label: 'Key is in testing mode (t=y)', detail: 'Testing mode means receiving servers should not reject messages that fail DKIM, even if the signature is invalid. Remove t=y to enable full enforcement.'});
}
if (keyType === 'rsa') {
issues.push({type: 'pass', label: 'Key type: RSA', detail: 'RSA is the standard and widely-supported DKIM key type.'});
} else if (keyType === 'ed25519') {
issues.push({type: 'pass', label: 'Key type: Ed25519', detail: 'Ed25519 provides strong security with smaller key sizes. Ensure your sending infrastructure supports it, as some older servers may not.'});
}
const publicKeySnippet = publicKey.length > 32 ? `${publicKey.slice(0, 32)}` : publicKey;
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
return {keyType, isRevoked, isTesting, publicKeySnippet, issues, grade};
}
function GradeBadge({grade}: {grade: 'pass' | 'warning' | 'fail'}) {
const map = {
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid', sub: 'DKIM key is active'},
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention', sub: 'DKIM has configuration issues'},
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Invalid', sub: 'DKIM key is revoked or invalid'},
};
const {cls, label, sub} = map[grade];
return (
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
</div>
);
}
const TAG_DESCRIPTIONS: Record<string, string> = {
v: 'DKIM version',
k: 'Key type (rsa or ed25519)',
p: 'Base64-encoded public key',
t: 'Flags (y=testing, s=strict service)',
s: 'Service type restriction',
h: 'Acceptable hash algorithms',
n: 'Notes (human-readable)',
};
const faqs: FAQ[] = [
{
question: 'What is DKIM?',
answer: 'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a digital signature to outgoing email. The signature is verified by receiving servers using a public key published in your DNS. If the signature matches, the email is confirmed to have originated from your domain and has not been tampered with in transit.',
},
{
question: 'What is a DKIM selector?',
answer: 'A DKIM selector is a label that identifies which DKIM key to use when there are multiple keys for a domain. Selectors are arbitrary strings chosen by the sending service (e.g., "google" for Google Workspace, "selector1" for Microsoft 365, "k1" for Mailchimp). The DKIM record is published at {selector}._domainkey.{domain}.',
},
{
question: 'Where do I find my DKIM selector?',
answer: 'Your DKIM selector is provided by your email sending service. In Google Workspace, it\'s typically "google". In Microsoft 365, it\'s "selector1" and "selector2". In Mailchimp, it\'s "k1". Check your email provider\'s DNS setup guide or look in the DKIM signature of a sent email (the "s=" tag in the DKIM-Signature header).',
},
{
question: 'Why is my DKIM key revoked?',
answer: 'A DKIM key is revoked by publishing a DKIM record with an empty p= value. This is intentional and signals that the key should no longer be used. Reasons include key rotation, key compromise, or switching email providers. If you didn\'t intentionally revoke the key, check your DNS records and publish a new DKIM key.',
},
{
question: 'Should I use RSA or Ed25519 for DKIM?',
answer: 'RSA (2048-bit) is the safest choice for maximum compatibility, as it is supported by all email providers. Ed25519 offers equivalent security with much smaller keys but is not supported by some older mail servers. A best practice is to publish both an RSA key and an Ed25519 key with different selectors, letting modern servers prefer Ed25519.',
},
];
interface DnsAnswer {
data: string;
}
interface DkimLookupResult {
domain: string;
selector: string;
found: boolean;
record: string | null;
tags: Record<string, string>;
error?: string;
}
function cleanTxt(raw: string): string {
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
}
async function lookupDkim(domain: string, selector: string): Promise<DkimLookupResult> {
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
const sel = selector.trim().toLowerCase();
try {
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`${sel}._domainkey.${clean}`)}&type=TXT`, {
headers: {Accept: 'application/dns-json'},
});
if (!res.ok) return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
const data = await res.json() as {Answer?: DnsAnswer[]};
const records = (data.Answer ?? [])
.map((a: DnsAnswer) => cleanTxt(a.data))
.filter(r => r.startsWith('v=DKIM1') || r.includes('k=') || r.includes('p='));
if (records.length === 0) return {domain: clean, selector: sel, found: false, record: null, tags: {}};
const record: string = records[0]!;
const tags: Record<string, string> = {};
record.split(';').forEach(part => {
const eqIdx = part.indexOf('=');
if (eqIdx > -1) {
const key = part.slice(0, eqIdx).trim();
const value = part.slice(eqIdx + 1).trim();
if (key) tags[key] = value;
}
});
return {domain: clean, selector: sel, found: true, record, tags};
} catch {
return {domain: clean, selector: sel, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
}
}
export default function DkimCheckerPage() {
const [domain, setDomain] = useState('');
const [selector, setSelector] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<DkimLookupResult | null>(null);
const [analysis, setAnalysis] = useState<DkimAnalysis | null>(null);
const handleCheck = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setResult(null);
setAnalysis(null);
try {
const data = await lookupDkim(domain, selector);
setResult(data);
if (data.found && data.tags) {
setAnalysis(analyzeDkim(data.tags));
}
} finally {
setLoading(false);
}
};
return (
<>
<NextSeo
title="DKIM Record Checker | Free DKIM Lookup & Validator | Plunk"
description="Free DKIM record checker. Look up your DKIM public key by domain and selector, validate the record, and get advice to fix misconfigurations."
canonical="https://www.useplunk.com/tools/dkim-checker"
openGraph={{
title: 'DKIM Record Checker | Free DKIM Lookup & Validator | Plunk',
description: 'Free DKIM record checker. Look up and validate your DKIM key by domain and selector.',
url: 'https://www.useplunk.com/tools/dkim-checker',
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DKIM+Record+Checker&tag=Tool', alt: 'Plunk DKIM Checker', width: 1200, height: 630}],
}}
/>
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
>
<span className={'font-medium text-neutral-900'}>§ T-06 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
DKIM record
<br />
checker
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Look up your domain&apos;s DKIM public key by selector. Verify the key is active, understand the
configuration, and get advice if something looks wrong.
</p>
</motion.div>
</div>
</section>
{/* ========== TOOL ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-2xl'}
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'border-b border-neutral-200 px-8 py-5'}>
<div className={'flex items-center gap-3'}>
<Key className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
DKIM record lookup
</span>
</div>
</div>
<form onSubmit={handleCheck} className={'p-8'}>
<div className={'space-y-4'}>
<div>
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Domain name <span className={'text-red-500'}>*</span>
</label>
<Input
id="domain"
type="text"
value={domain}
onChange={e => setDomain(e.target.value)}
placeholder="example.com"
required
className={'w-full'}
/>
</div>
<div>
<label htmlFor="selector" className={'mb-2 block text-sm font-medium text-neutral-900'}>
DKIM selector <span className={'text-red-500'}>*</span>
</label>
<Input
id="selector"
type="text"
value={selector}
onChange={e => setSelector(e.target.value)}
placeholder="google"
required
className={'w-full'}
/>
<p className={'mt-1.5 text-xs text-neutral-400'}>
Not sure? Try a common selector below.
</p>
</div>
{/* Common selectors */}
<div>
<p className={'mb-2 text-xs font-medium text-neutral-500'}>Common selectors</p>
<div className={'flex flex-wrap gap-2'}>
{COMMON_SELECTORS.map(s => (
<button
key={s.value}
type="button"
onClick={() => setSelector(s.value)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
selector === s.value
? 'border-neutral-900 bg-neutral-900 text-white'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-400 hover:text-neutral-900'
}`}
>
{s.label} <span style={{fontFamily: 'var(--font-mono)'}} className={'opacity-60'}>({s.value})</span>
</button>
))}
</div>
</div>
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
<Key className={'h-4 w-4'} />
{loading ? 'Looking up DKIM record…' : 'Check DKIM record'}
</Button>
</div>
</form>
</div>
{result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6 space-y-4'}
>
{!result.found ? (
<div className={'rounded-[20px] border border-amber-100 bg-amber-50 p-8'}>
<div className={'flex items-start gap-3'}>
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-600'} />
<div>
<p className={'font-semibold text-amber-900'}>No DKIM record found</p>
<p className={'mt-1 text-sm text-amber-800'}>
{result.error
? 'DNS lookup failed. Please check the domain and try again.'
: `No DKIM record was found at `}
{!result.error && (
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-xs'}>
{result.selector}._domainkey.{result.domain}
</code>
)}
</p>
{!result.error && (
<p className={'mt-2 text-sm text-amber-700'}>
Check that you are using the correct selector. If your email provider has given you a specific selector, use that. If the record still doesn&apos;t appear, DNS propagation may still be in progress.
</p>
)}
</div>
</div>
</div>
) : (
<>
{/* Lookup host */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Record found at
</p>
<code
style={{fontFamily: 'var(--font-mono)'}}
className={'text-sm font-medium text-neutral-700'}
>
{result.selector}._domainkey.{result.domain}
</code>
{result.record && (
<>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-2 mt-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Raw record
</p>
<code
style={{fontFamily: 'var(--font-mono)'}}
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
>
{result.record.length > 200
? `${result.record.slice(0, 200)}… [${result.record.length - 200} more characters]`
: result.record}
</code>
</>
)}
</div>
{analysis && (
<>
{/* Grade */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<div className={'flex flex-col items-center gap-4 text-center'}>
<GradeBadge grade={analysis.grade} />
</div>
</div>
{/* Tags */}
{Object.keys(result.tags).length > 0 && (
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mb-6 text-lg font-bold text-neutral-900'}
>
Record tags
</h3>
<div className={'space-y-2'}>
{Object.entries(result.tags).map(([key, value]) => {
const displayValue = key === 'p' && value.length > 48
? `${value.slice(0, 48)}… [${value.length - 48} more chars]`
: value || '(empty — key revoked)';
return (
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<div className={'flex min-w-0 flex-1 flex-col gap-0.5'}>
<div className={'flex items-center gap-2'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold text-neutral-700'}>
{key}=
</span>
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
{displayValue}
</span>
</div>
{TAG_DESCRIPTIONS[key] && (
<span className={'text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Analysis */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mb-6 text-lg font-bold text-neutral-900'}
>
Analysis &amp; recommendations
</h3>
<ul className={'space-y-3'}>
{analysis.issues.map((issue, i) => (
<li key={i} className={'flex items-start gap-3'}>
{issue.type === 'pass' ? (
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
) : issue.type === 'warning' ? (
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
) : (
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
)}
<div>
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
</div>
</li>
))}
</ul>
</div>
</>
)}
</>
)}
</motion.div>
)}
</motion.div>
</section>
{/* ========== EDUCATION ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'DKIM explained'}
title={'How DKIM signing works.'}
subtitle={'DKIM proves your email was sent by your domain and wasn\'t altered in transit.'}
/>
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{
title: 'Cryptographic signature',
body: 'Your sending server signs each email using a private key. The corresponding public key is published in DNS. Receiving servers verify the signature to confirm authenticity.',
},
{
title: 'Selector system',
body: 'Each DKIM key is identified by a selector. You can have multiple selectors (and keys) per domain, allowing key rotation and multiple sending providers at the same time.',
},
{
title: 'Tamper detection',
body: 'The DKIM signature covers specific email headers and the body. If the email is modified in transit, the signature breaks and DKIM fails — protecting against content manipulation.',
},
{
title: 'Key rotation',
body: 'Best practice is to rotate DKIM keys annually. Publish the new key under a different selector, update your sending infrastructure, then revoke the old key by setting p= to empty.',
},
{
title: 'DKIM alone is not enough',
body: 'Like SPF, DKIM authentication alone doesn\'t protect the visible From header. You need DMARC to enforce authentication policies and protect against spoofing.',
},
{
title: '2048-bit RSA minimum',
body: '1024-bit RSA keys are considered insecure. Use at least 2048-bit RSA or switch to Ed25519, which provides equivalent security with much smaller keys.',
},
].map((item, i) => (
<motion.div
key={item.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Sign every email. Reach the inbox.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Plunk configures DKIM signing automatically and guides you through setting up SPF and DMARC for your
domain.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
<Link
href="/guides/what-is-dkim"
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
What is DKIM?
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
<FAQSection faqs={faqs} schemaId="faq-dkim-checker" />
<Footer />
</>
);
}
@@ -0,0 +1,584 @@
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
import {AlertTriangle, ArrowRight, CheckCircle, ShieldCheck, XCircle} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
interface DmarcIssue {
type: 'error' | 'warning' | 'pass';
label: string;
detail: string;
}
interface DmarcAnalysis {
policy: string;
subPolicy: string | null;
pct: number;
hasRua: boolean;
hasRuf: boolean;
adkim: string;
aspf: string;
issues: DmarcIssue[];
grade: 'pass' | 'warning' | 'fail';
}
function analyzeDmarc(tags: Record<string, string>): DmarcAnalysis {
const policy = tags['p'] ?? '';
const subPolicy = tags['sp'] ?? null;
const pct = parseInt(tags['pct'] ?? '100', 10);
const hasRua = Boolean(tags['rua']);
const hasRuf = Boolean(tags['ruf']);
const adkim = tags['adkim'] ?? 'r';
const aspf = tags['aspf'] ?? 'r';
const issues: DmarcIssue[] = [];
// Policy
if (!policy) {
issues.push({type: 'error', label: 'Missing policy (p=)', detail: 'The p= tag is required. Set p=none to monitor, p=quarantine to send to spam, or p=reject to block.'});
} else if (policy === 'none') {
issues.push({type: 'warning', label: 'Policy is p=none (monitoring only)', detail: 'p=none means DMARC failures are reported but no action is taken. Upgrade to p=quarantine then p=reject once you confirm legitimate mail passes.'});
} else if (policy === 'quarantine') {
issues.push({type: 'warning', label: 'Policy is p=quarantine', detail: 'Failing messages are sent to spam/junk. Consider upgrading to p=reject for full protection.'});
} else if (policy === 'reject') {
issues.push({type: 'pass', label: 'Policy is p=reject — maximum protection', detail: 'Failing messages are rejected outright. This is the strongest DMARC policy.'});
}
// Percentage
if (pct < 100) {
issues.push({type: 'warning', label: `Policy applies to only ${pct}% of messages`, detail: `pct=${pct} means DMARC enforcement only applies to ${pct}% of failing mail. Set pct=100 for full enforcement.`});
} else if (policy && policy !== 'none') {
issues.push({type: 'pass', label: 'Policy applies to 100% of messages', detail: 'DMARC enforcement is fully deployed.'});
}
// Aggregate reports
if (!hasRua) {
issues.push({type: 'warning', label: 'No aggregate reporting (rua= missing)', detail: 'Without rua=, you receive no DMARC aggregate reports. Add rua=mailto:dmarc@yourdomain.com or use a DMARC reporting service to monitor your authentication results.'});
} else {
issues.push({type: 'pass', label: 'Aggregate reports configured (rua=)', detail: 'You will receive DMARC aggregate reports to monitor SPF and DKIM alignment.'});
}
// Alignment
if (adkim === 's') {
issues.push({type: 'pass', label: 'DKIM alignment: strict', detail: 'Strict DKIM alignment requires the d= domain in the DKIM signature to exactly match the From domain.'});
}
if (aspf === 's') {
issues.push({type: 'pass', label: 'SPF alignment: strict', detail: 'Strict SPF alignment requires the envelope sender domain to exactly match the From domain.'});
}
const grade = issues.some(i => i.type === 'error') ? 'fail' : issues.some(i => i.type === 'warning') ? 'warning' : 'pass';
return {policy, subPolicy, pct, hasRua, hasRuf, adkim, aspf, issues, grade};
}
function PolicyBadge({policy}: {policy: string}) {
if (policy === 'reject') {
return <span className={'rounded-full bg-green-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-green-700 border border-green-200'}>reject</span>;
}
if (policy === 'quarantine') {
return <span className={'rounded-full bg-amber-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-amber-700 border border-amber-200'}>quarantine</span>;
}
if (policy === 'none') {
return <span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-bold uppercase tracking-wide text-neutral-600 border border-neutral-300'}>none</span>;
}
return <span className={'rounded-full bg-red-50 px-3 py-1 text-xs font-bold uppercase tracking-wide text-red-700 border border-red-200'}>missing</span>;
}
function GradeBadge({grade, policy}: {grade: 'pass' | 'warning' | 'fail'; policy: string}) {
const map = {
pass: {cls: 'bg-green-50 border-green-200 text-green-700', label: 'Valid'},
warning: {cls: 'bg-amber-50 border-amber-200 text-amber-700', label: 'Needs attention'},
fail: {cls: 'bg-red-50 border-red-200 text-red-700', label: 'Action required'},
};
const {cls, label} = map[grade];
const sub = policy === 'reject' ? 'Full DMARC protection' : policy === 'quarantine' ? 'Partial protection' : policy === 'none' ? 'Monitoring only — no enforcement' : 'DMARC not enforcing';
return (
<div className={`flex flex-col items-center gap-1 rounded-2xl border-2 px-8 py-5 ${cls}`}>
<span style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-extrabold'}>{label}</span>
<span className={'text-xs font-medium opacity-80'}>{sub}</span>
</div>
);
}
const TAG_DESCRIPTIONS: Record<string, string> = {
v: 'DMARC version',
p: 'Domain policy for failing messages',
sp: 'Subdomain policy override',
pct: 'Percentage of messages subject to policy',
rua: 'Aggregate report recipients',
ruf: 'Forensic report recipients',
adkim: 'DKIM alignment mode (r=relaxed, s=strict)',
aspf: 'SPF alignment mode (r=relaxed, s=strict)',
fo: 'Failure reporting options',
rf: 'Forensic report format',
ri: 'Reporting interval (seconds)',
};
const faqs: FAQ[] = [
{
question: 'What is DMARC?',
answer: 'DMARC (Domain-based Message Authentication, Reporting & Conformance) is an email authentication policy that builds on SPF and DKIM. It tells receiving mail servers what to do when an email fails authentication — none (monitor), quarantine (spam folder), or reject (block). DMARC also enables reporting so you can see who is sending email from your domain.',
},
{
question: 'What is the difference between p=none, quarantine, and reject?',
answer: 'p=none means DMARC is in monitoring mode — failures are reported but emails are still delivered. p=quarantine instructs receiving servers to put failing messages in the spam/junk folder. p=reject instructs servers to reject failing messages entirely. The recommended path is to start at p=none, review reports, then progress to quarantine and finally reject.',
},
{
question: 'What are DMARC aggregate reports?',
answer: 'Aggregate reports (rua=) are XML reports sent by receiving mail servers summarising how many messages passed or failed SPF and DKIM for your domain. They help you identify all sources sending on your behalf, catch misconfigurations, and detect spoofing attempts. Use a DMARC reporting service to parse and visualise these reports.',
},
{
question: 'Why do I need DMARC if I already have SPF and DKIM?',
answer: 'SPF and DKIM independently authenticate different aspects of an email, but neither specifies what to do when authentication fails. DMARC ties them together and enforces a policy. Without DMARC, even a domain with perfect SPF and DKIM offers no protection against spoofing of the visible From header.',
},
{
question: 'What is DMARC alignment?',
answer: 'DMARC alignment requires that the domain in a passing SPF or DKIM check matches (or aligns with) the From header domain. Relaxed alignment (r) allows subdomains; strict alignment (s) requires an exact domain match. Alignment is what connects SPF/DKIM to the From header the user sees.',
},
];
interface DnsAnswer {
data: string;
}
interface DmarcLookupResult {
domain: string;
found: boolean;
record: string | null;
tags: Record<string, string>;
error?: string;
}
function cleanTxt(raw: string): string {
return raw.replace(/^"|"$/g, '').replace(/"\s*"/g, '');
}
async function lookupDmarc(domain: string): Promise<DmarcLookupResult> {
const clean = domain.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
try {
const res = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(`_dmarc.${clean}`)}&type=TXT`, {
headers: {Accept: 'application/dns-json'},
});
if (!res.ok) return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
const data = await res.json() as {Answer?: DnsAnswer[]};
const records = (data.Answer ?? []).map((a: DnsAnswer) => cleanTxt(a.data)).filter(r => r.startsWith('v=DMARC1'));
if (records.length === 0) return {domain: clean, found: false, record: null, tags: {}};
const record: string = records[0]!;
const tags: Record<string, string> = {};
record.split(';').forEach(part => {
const eqIdx = part.indexOf('=');
if (eqIdx > -1) {
const key = part.slice(0, eqIdx).trim();
const value = part.slice(eqIdx + 1).trim();
if (key) tags[key] = value;
}
});
return {domain: clean, found: true, record, tags};
} catch {
return {domain: clean, found: false, record: null, tags: {}, error: 'DNS lookup failed'};
}
}
export default function DmarcCheckerPage() {
const [domain, setDomain] = useState('');
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<DmarcLookupResult | null>(null);
const [analysis, setAnalysis] = useState<DmarcAnalysis | null>(null);
const handleCheck = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setResult(null);
setAnalysis(null);
try {
const data = await lookupDmarc(domain);
setResult(data);
if (data.found && data.tags) {
setAnalysis(analyzeDmarc(data.tags));
}
} finally {
setLoading(false);
}
};
return (
<>
<NextSeo
title="DMARC Record Checker | Free DMARC Lookup & Validator | Plunk"
description="Free DMARC record checker. Look up and validate your domain's DMARC policy, check reporting configuration, and get step-by-step advice to strengthen email security."
canonical="https://www.useplunk.com/tools/dmarc-checker"
openGraph={{
title: 'DMARC Record Checker | Free DMARC Lookup & Validator | Plunk',
description: 'Free DMARC record checker. Validate your DMARC policy and get actionable advice to protect your domain from spoofing.',
url: 'https://www.useplunk.com/tools/dmarc-checker',
images: [{url: 'https://www.useplunk.com/api/og?title=Free+DMARC+Record+Checker&tag=Tool', alt: 'Plunk DMARC Checker', width: 1200, height: 630}],
}}
/>
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
>
<span className={'font-medium text-neutral-900'}>§ T-05 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
DMARC record
<br />
checker
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Look up and validate your domain&apos;s DMARC record. Understand your current policy, reporting
configuration, and get clear advice to progress toward full enforcement.
</p>
</motion.div>
</div>
</section>
{/* ========== TOOL ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-2xl'}
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'border-b border-neutral-200 px-8 py-5'}>
<div className={'flex items-center gap-3'}>
<ShieldCheck className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
DMARC record lookup
</span>
</div>
</div>
<form onSubmit={handleCheck} className={'p-8'}>
<div className={'space-y-4'}>
<div>
<label htmlFor="domain" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Domain name <span className={'text-red-500'}>*</span>
</label>
<Input
id="domain"
type="text"
value={domain}
onChange={e => setDomain(e.target.value)}
placeholder="example.com"
required
className={'w-full'}
/>
<p className={'mt-1.5 text-xs text-neutral-400'}>Enter the domain without http:// or www.</p>
</div>
<Button type="submit" className={'w-full gap-2'} disabled={loading}>
<ShieldCheck className={'h-4 w-4'} />
{loading ? 'Looking up DMARC record…' : 'Check DMARC record'}
</Button>
</div>
</form>
</div>
{result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6 space-y-4'}
>
{!result.found ? (
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-8'}>
<div className={'flex items-start gap-3'}>
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
<div>
<p className={'font-semibold text-red-900'}>No DMARC record found</p>
<p className={'mt-1 text-sm text-red-700'}>
{result.error
? 'DNS lookup failed. Please check the domain and try again.'
: `No DMARC record was found at _dmarc.${result.domain}. Without DMARC, your domain has no enforcement policy and you won't receive authentication reports.`}
</p>
<p className={'mt-3 text-sm font-medium text-red-800'}>
Add a TXT record to <span style={{fontFamily: 'var(--font-mono)'}}>_dmarc.{result.domain}</span>:
</p>
<code
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-2 block rounded-lg bg-red-100 px-4 py-3 text-xs text-red-900 break-all'}
>
{`v=DMARC1; p=none; rua=mailto:dmarc@${result.domain}`}
</code>
<p className={'mt-2 text-xs text-red-600'}>Start with p=none to monitor, then progress to quarantine and reject.</p>
</div>
</div>
</div>
) : (
<>
{/* Raw record */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-2 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Raw record _dmarc.{result.domain}
</p>
<code
style={{fontFamily: 'var(--font-mono)'}}
className={'block break-all rounded-lg bg-neutral-50 px-4 py-3 text-xs text-neutral-700'}
>
{result.record}
</code>
</div>
{analysis && (
<>
{/* Grade */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<div className={'flex flex-col items-center gap-4 text-center'}>
<GradeBadge grade={analysis.grade} policy={analysis.policy} />
</div>
</div>
{/* Tags */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mb-6 text-lg font-bold text-neutral-900'}
>
Record tags
</h3>
<div className={'space-y-2'}>
{Object.entries(result.tags).map(([key, value]) => (
<div key={key} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<div className={'flex min-w-0 flex-1 items-center gap-3'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'shrink-0 text-xs font-bold text-neutral-700'}>
{key}=
</span>
{key === 'p' || key === 'sp' ? (
<PolicyBadge policy={value} />
) : (
<span style={{fontFamily: 'var(--font-mono)'}} className={'truncate text-xs text-neutral-500'}>
{value}
</span>
)}
</div>
{TAG_DESCRIPTIONS[key] && (
<span className={'shrink-0 text-xs text-neutral-400'}>{TAG_DESCRIPTIONS[key]}</span>
)}
</div>
))}
</div>
</div>
{/* Analysis */}
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mb-6 text-lg font-bold text-neutral-900'}
>
Analysis &amp; recommendations
</h3>
<ul className={'space-y-3'}>
{analysis.issues.map((issue, i) => (
<li key={i} className={'flex items-start gap-3'}>
{issue.type === 'pass' ? (
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
) : issue.type === 'warning' ? (
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
) : (
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
)}
<div>
<p className={'text-sm font-medium text-neutral-900'}>{issue.label}</p>
<p className={'mt-0.5 text-xs text-neutral-500'}>{issue.detail}</p>
</div>
</li>
))}
</ul>
</div>
</>
)}
</>
)}
</motion.div>
)}
</motion.div>
</section>
{/* ========== EDUCATION ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'DMARC explained'}
title={'From monitoring to full enforcement.'}
subtitle={'DMARC is a journey. Start with none, build confidence, then enforce.'}
/>
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
{[
{
step: '01',
policy: 'p=none',
title: 'Monitor',
body: 'Start here. DMARC is active but no action is taken on failures. Add rua= to receive aggregate reports and identify all your sending sources.',
cls: 'border-neutral-300',
},
{
step: '02',
policy: 'p=quarantine',
title: 'Quarantine',
body: 'Once you\'re confident all legitimate senders pass, move to quarantine. Failing messages are sent to spam, reducing spoofing impact.',
cls: 'border-amber-300',
},
{
step: '03',
policy: 'p=reject',
title: 'Reject',
body: 'Full enforcement. Failing messages are rejected by receiving servers. This is the goal — it completely prevents domain spoofing.',
cls: 'border-green-400',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
>
<div className={'flex items-center justify-between'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</span>
<code
style={{fontFamily: 'var(--font-mono)'}}
className={'rounded bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700'}
>
{item.policy}
</code>
</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Email that reaches the inbox.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Plunk walks you through SPF, DKIM, and DMARC setup and monitors your sending reputation over time.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
<Link
href="/guides/what-is-dmarc"
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
What is DMARC?
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
<FAQSection faqs={faqs} schemaId="faq-dmarc-checker" />
<Footer />
</>
);
}
@@ -0,0 +1,655 @@
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
import {AlertTriangle, ArrowRight, CheckCircle, FileText, XCircle} from 'lucide-react';
import {Button} from '@plunk/ui';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
import type {FAQ} from '../../components/FAQSection';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
interface AuthResult {
mechanism: 'spf' | 'dkim' | 'dmarc';
result: string;
detail: string;
pass: boolean | null;
}
interface ReceivedHop {
from: string;
by: string;
timestamp: string | null;
raw: string;
}
interface ParsedHeaders {
from: string | null;
to: string | null;
subject: string | null;
date: string | null;
messageId: string | null;
replyTo: string | null;
returnPath: string | null;
xMailer: string | null;
dkimSelector: string | null;
dkimDomain: string | null;
authResults: AuthResult[];
hops: ReceivedHop[];
}
function parseRawHeaders(raw: string): Record<string, string[]> {
const headers: Record<string, string[]> = {};
const lines = raw.split(/\r?\n/);
let currentKey = '';
let currentValue = '';
for (const line of lines) {
if (/^\s/.test(line) && currentKey) {
currentValue += ' ' + line.trim();
} else {
if (currentKey) {
const existing = headers[currentKey];
if (existing) existing.push(currentValue.trim());
else headers[currentKey] = [currentValue.trim()];
}
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
currentKey = line.slice(0, colonIdx).trim().toLowerCase();
currentValue = line.slice(colonIdx + 1).trim();
} else {
currentKey = '';
currentValue = '';
}
}
}
if (currentKey) {
const existing = headers[currentKey];
if (existing) existing.push(currentValue.trim());
else headers[currentKey] = [currentValue.trim()];
}
return headers;
}
function parseAuthResults(authHeader: string): AuthResult[] {
const results: AuthResult[] = [];
const lower = authHeader.toLowerCase();
const mechanisms: Array<'spf' | 'dkim' | 'dmarc'> = ['spf', 'dkim', 'dmarc'];
for (const mech of mechanisms) {
const mechIdx = lower.indexOf(mech + '=');
if (mechIdx === -1) continue;
const afterMech = authHeader.slice(mechIdx + mech.length + 1);
const resultMatch = /^(\w+)/.exec(afterMech);
const result = resultMatch?.[1]?.toLowerCase() ?? 'unknown';
const pass =
result === 'pass'
? true
: result === 'fail' || result === 'hardfail' || result === 'none'
? false
: null;
let detail = '';
if (mech === 'spf') {
const smtpMatch = /smtp\.mailfrom\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
if (smtpMatch?.[1]) detail = `smtp.mailfrom=${smtpMatch[1]}`;
} else if (mech === 'dkim') {
const headerMatch = /header\.i\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
if (headerMatch?.[1]) detail = `header.i=${headerMatch[1]}`;
} else if (mech === 'dmarc') {
const headerMatch = /header\.from\s*=\s*([^\s;]+)/i.exec(authHeader.slice(mechIdx));
if (headerMatch?.[1]) detail = `header.from=${headerMatch[1]}`;
}
results.push({mechanism: mech, result, detail, pass});
}
return results;
}
function parseReceivedHop(receivedValue: string): ReceivedHop {
const fromMatch = /from\s+([^\s]+)/i.exec(receivedValue);
const byMatch = /by\s+([^\s]+)/i.exec(receivedValue);
const datePattern = /;\s*(.+)$/;
const dateMatch = datePattern.exec(receivedValue);
let timestamp: string | null = null;
if (dateMatch?.[1]) {
const parsed = new Date(dateMatch[1].trim());
if (!isNaN(parsed.getTime())) {
timestamp = parsed.toLocaleString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
});
}
}
return {
from: fromMatch?.[1] ?? '(unknown)',
by: byMatch?.[1] ?? '(unknown)',
timestamp,
raw: receivedValue,
};
}
function parseDkimSignature(dkimValue: string): {selector: string | null; domain: string | null} {
const sMatch = /\bs\s*=\s*([^;\s]+)/i.exec(dkimValue);
const dMatch = /\bd\s*=\s*([^;\s]+)/i.exec(dkimValue);
return {selector: sMatch?.[1] ?? null, domain: dMatch?.[1] ?? null};
}
function analyzeHeaders(raw: string): ParsedHeaders | null {
if (!raw.trim()) return null;
const headers = parseRawHeaders(raw);
const first = (key: string) => headers[key]?.[0] ?? null;
const authHeader = first('authentication-results');
const authResults = authHeader ? parseAuthResults(authHeader) : [];
const receivedHeaders = headers['received'] ?? [];
const hops = receivedHeaders.map(parseReceivedHop).reverse();
const dkimSig = first('dkim-signature');
const {selector, domain} = dkimSig ? parseDkimSignature(dkimSig) : {selector: null, domain: null};
return {
from: first('from'),
to: first('to'),
subject: first('subject'),
date: first('date'),
messageId: first('message-id'),
replyTo: first('reply-to'),
returnPath: first('return-path'),
xMailer: first('x-mailer') ?? first('x-mailing-list') ?? null,
dkimSelector: selector,
dkimDomain: domain,
authResults,
hops,
};
}
function AuthBadge({result}: {result: string}) {
const pass = result === 'pass';
const fail = result === 'fail' || result === 'hardfail';
const cls = pass
? 'bg-green-100 text-green-700'
: fail
? 'bg-red-100 text-red-700'
: 'bg-neutral-100 text-neutral-600';
return (
<span style={{fontFamily: 'var(--font-mono)'}} className={`rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}>
{result}
</span>
);
}
const faqs: FAQ[] = [
{
question: 'Where do I find the raw email headers?',
answer: 'In Gmail: open the email, click the three-dot menu (⋮), choose "Show original". In Outlook: open the email, click File → Properties, and copy the "Internet headers" box. In Apple Mail: with the email open, go to View → Message → All Headers. The raw headers appear at the top of the message source.',
},
{
question: 'What do the Authentication-Results headers mean?',
answer: 'Authentication-Results is added by the receiving mail server and summarises the SPF, DKIM, and DMARC check results. "pass" means the check succeeded. "fail" or "hardfail" means it failed. "none" means no record was found. If DMARC passes but SPF or DKIM fails, check that your sending domain aligns with the From header.',
},
{
question: 'How do I read the Received chain?',
answer: 'Received headers are added by each mail server the message passed through, with the most recent at the top. Reading them bottom-to-top gives you the routing path from sender to inbox. Large time gaps between hops indicate server delays or queuing. The very first Received header shows where the message originated.',
},
{
question: 'Why does email land in spam even though SPF and DKIM pass?',
answer: 'Authentication passing is necessary but not sufficient. Spam filters also consider sender reputation, IP blacklist status, content quality, engagement history, and DMARC alignment. A brand-new IP, a domain with no sending history, or content with many spam trigger words can cause filtering even with perfect authentication.',
},
{
question: 'What is the DKIM-Signature header?',
answer: 'DKIM-Signature contains a cryptographic signature applied by the sending server. The "d=" tag identifies the signing domain and "s=" is the selector used to locate the public key in DNS. Receiving servers verify the signature against the public key at <selector>._domainkey.<domain>. A mismatch or missing key causes a DKIM fail.',
},
];
export default function EmailHeadersPage() {
const [raw, setRaw] = useState('');
const [result, setResult] = useState<ParsedHeaders | null>(null);
const [analysed, setAnalysed] = useState(false);
const handleAnalyze = (e: React.FormEvent) => {
e.preventDefault();
const parsed = analyzeHeaders(raw);
setResult(parsed);
setAnalysed(true);
};
const handleClear = () => {
setRaw('');
setResult(null);
setAnalysed(false);
};
const metaFields: Array<{label: string; value: string | null}> = result
? [
{label: 'From', value: result.from},
{label: 'To', value: result.to},
{label: 'Subject', value: result.subject},
{label: 'Date', value: result.date},
{label: 'Message-ID', value: result.messageId},
{label: 'Reply-To', value: result.replyTo},
{label: 'Return-Path', value: result.returnPath},
{label: 'X-Mailer', value: result.xMailer},
].filter(f => f.value !== null)
: [];
return (
<>
<NextSeo
title="Email Headers Analyzer | Parse & Debug Email Headers | Plunk"
description="Free email headers analyzer. Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops, and authentication chain. No sign-up required."
canonical="https://www.useplunk.com/tools/email-headers"
openGraph={{
title: 'Email Headers Analyzer | Parse & Debug Email Headers | Plunk',
description: 'Paste raw email headers and instantly see SPF/DKIM/DMARC results, routing hops, and authentication chain. Free, no sign-up.',
url: 'https://www.useplunk.com/tools/email-headers',
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Headers+Analyzer&tag=Tool', alt: 'Plunk Email Headers Analyzer', width: 1200, height: 630}],
}}
/>
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'}
>
<span className={'font-medium text-neutral-900'}>§ T-09 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
Email headers
<br />
analyzer
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Paste raw email headers and get a parsed breakdown of SPF, DKIM, and DMARC results, routing hops with timestamps, and the full authentication chain.
</p>
</motion.div>
</div>
</section>
{/* ========== TOOL ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl'}
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'border-b border-neutral-200 px-8 py-5'}>
<div className={'flex items-center gap-3'}>
<FileText className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Raw header input
</span>
</div>
</div>
<form onSubmit={handleAnalyze} className={'p-8'}>
<div className={'space-y-4'}>
<div>
<label htmlFor="rawHeaders" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Raw email headers <span className={'text-red-500'}>*</span>
</label>
<textarea
id="rawHeaders"
value={raw}
onChange={e => setRaw(e.target.value)}
placeholder={'Paste raw headers here.\n\nIn Gmail: ⋮ menu → Show original\nIn Outlook: File → Properties → Internet headers\nIn Apple Mail: View → Message → All Headers'}
required
rows={10}
style={{fontFamily: 'var(--font-mono)'}}
className={'w-full resize-y rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3 text-xs text-neutral-700 placeholder:text-neutral-400 focus:border-neutral-400 focus:bg-white focus:outline-none'}
/>
<p className={'mt-1.5 text-xs text-neutral-400'}>Paste the full raw headers no email body required. Everything is processed locally in your browser.</p>
</div>
<div className={'flex gap-3'}>
<Button type="submit" className={'flex-1 gap-2'}>
<FileText className={'h-4 w-4'} />
Analyze headers
</Button>
{analysed && (
<button
type="button"
onClick={handleClear}
className={'rounded-full border border-neutral-200 px-5 py-2.5 text-sm font-medium text-neutral-600 transition hover:border-neutral-400 hover:text-neutral-900'}
>
Clear
</button>
)}
</div>
</div>
</form>
</div>
{analysed && result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6 space-y-4'}
>
{/* Authentication results */}
{result.authResults.length > 0 && (
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
Authentication results
</h3>
<ul className={'space-y-3'}>
{result.authResults.map((auth, i) => (
<li key={i} className={'flex items-start gap-3 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
{auth.pass === true ? (
<CheckCircle className={'mt-0.5 h-4 w-4 shrink-0 text-green-600'} />
) : auth.pass === false ? (
<XCircle className={'mt-0.5 h-4 w-4 shrink-0 text-red-500'} />
) : (
<AlertTriangle className={'mt-0.5 h-4 w-4 shrink-0 text-amber-500'} />
)}
<div className={'min-w-0 flex-1'}>
<div className={'flex flex-wrap items-center gap-2'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs font-bold uppercase tracking-wide text-neutral-700'}>
{auth.mechanism}
</span>
<AuthBadge result={auth.result} />
{auth.detail && (
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-xs text-neutral-400'}>
{auth.detail}
</span>
)}
</div>
</div>
</li>
))}
</ul>
</div>
)}
{result.authResults.length === 0 && (
<div className={'rounded-[20px] border border-neutral-100 bg-neutral-50 p-6'}>
<div className={'flex items-start gap-3'}>
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
<div>
<p className={'text-sm font-medium text-neutral-800'}>No Authentication-Results header found</p>
<p className={'mt-0.5 text-xs text-neutral-500'}>This header is added by the receiving mail server. If it&apos;s missing, paste the full original headers, not just the visible ones.</p>
</div>
</div>
</div>
)}
{/* DKIM signature info */}
{(result.dkimDomain ?? result.dkimSelector) && (
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-4 text-lg font-bold text-neutral-900'}>
DKIM signature
</h3>
<div className={'flex flex-wrap gap-4'}>
{result.dkimDomain && (
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Signing domain</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimDomain}</p>
</div>
)}
{result.dkimSelector && (
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Selector</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>{result.dkimSelector}</p>
</div>
)}
{result.dkimDomain && result.dkimSelector && (
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Public key DNS name</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-1 text-sm font-medium text-neutral-900'}>
{result.dkimSelector}._domainkey.{result.dkimDomain}
</p>
</div>
)}
</div>
</div>
)}
{/* Routing hops */}
{result.hops.length > 0 && (
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
Routing hops <span className={'text-base font-normal text-neutral-400'}>({result.hops.length} server{result.hops.length !== 1 ? 's' : ''})</span>
</h3>
<ol className={'space-y-3'}>
{result.hops.map((hop, i) => (
<li key={i} className={'flex gap-4'}>
<div className={'flex flex-col items-center'}>
<div className={'flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-white'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[10px] font-bold text-neutral-500'}>
{i + 1}
</span>
</div>
{i < result.hops.length - 1 && <div className={'mt-1 h-full w-px bg-neutral-200'} />}
</div>
<div className={'min-w-0 flex-1 pb-3'}>
<div className={'rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<div className={'flex flex-wrap items-start justify-between gap-2'}>
<div className={'min-w-0'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>From</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.from}</p>
</div>
<div className={'min-w-0'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>By</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 truncate text-xs font-medium text-neutral-800'}>{hop.by}</p>
</div>
{hop.timestamp && (
<div className={'shrink-0'}>
<p className={'text-[10px] uppercase tracking-[0.15em] text-neutral-400'}>Time</p>
<p style={{fontFamily: 'var(--font-mono)'}} className={'mt-0.5 text-xs text-neutral-600'}>{hop.timestamp}</p>
</div>
)}
</div>
</div>
</div>
</li>
))}
</ol>
</div>
)}
{/* Message metadata */}
{metaFields.length > 0 && (
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mb-6 text-lg font-bold text-neutral-900'}>
Message metadata
</h3>
<dl className={'space-y-3'}>
{metaFields.map(field => (
<div key={field.label} className={'flex flex-col gap-1 rounded-lg border border-neutral-100 bg-neutral-50 px-4 py-3 sm:flex-row sm:gap-4'}>
<dt style={{fontFamily: 'var(--font-mono)'}} className={'w-28 shrink-0 text-xs font-bold text-neutral-500'}>
{field.label}
</dt>
<dd style={{fontFamily: 'var(--font-mono)'}} className={'min-w-0 break-all text-xs text-neutral-800'}>
{field.value}
</dd>
</div>
))}
</dl>
</div>
)}
</motion.div>
)}
{analysed && !result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6 rounded-[20px] border border-red-100 bg-red-50 p-8'}
>
<div className={'flex items-start gap-3'}>
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
<div>
<p className={'font-semibold text-red-900'}>Could not parse headers</p>
<p className={'mt-1 text-sm text-red-700'}>Make sure you pasted raw email headers in standard format. Each header should be on its own line followed by a colon and value.</p>
</div>
</div>
</motion.div>
)}
</motion.div>
</section>
{/* ========== EDUCATION ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'Email headers explained'}
title={'What headers tell you.'}
subtitle={'Every email carries a forensic trail. Here\'s what to look for when debugging deliverability.'}
/>
<div className={'mt-20 grid gap-6 sm:grid-cols-3'}>
{[
{
tag: 'Authentication',
title: 'SPF, DKIM & DMARC',
body: 'The Authentication-Results header is your first stop. It shows whether the sending server was authorised (SPF), the signature was valid (DKIM), and whether both align with the From domain (DMARC).',
cls: 'border-neutral-300',
},
{
tag: 'Routing',
title: 'Received chain',
body: 'Received headers trace the path of your email from origin to inbox. Read them bottom-to-top. Large time gaps reveal where delays happened — useful for diagnosing why an email arrived late.',
cls: 'border-neutral-300',
},
{
tag: 'Identity',
title: 'Return-Path & Reply-To',
body: 'Return-Path is where bounces go — it must pass SPF. Reply-To overrides where replies are sent. A mismatch between From, Return-Path, and Reply-To can trigger spam filters or indicate phishing.',
cls: 'border-neutral-300',
},
].map((item, i) => (
<motion.div
key={item.tag}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={`flex flex-col gap-5 rounded-[20px] border-2 bg-white p-8 ${item.cls}`}
>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>
/ {item.tag}
</span>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
{item.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Never debug deliverability again.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Plunk sets up authentication correctly from day one and gives you the analytics to catch deliverability issues before your users do.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
<Link
href="/tools/dkim-checker"
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
Check DKIM record
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
<FAQSection faqs={faqs} schemaId="faq-email-headers" />
<Footer />
</>
);
}
+310 -213
View File
@@ -1,268 +1,365 @@
import {Footer, Navbar} from '../../components';
import {Footer, Navbar, SectionHeader} 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';
import {ArrowRight, ArrowUpRight, Code2, FileText, Key, Mail, Search, Shield, ShieldAlert, ShieldCheck} from 'lucide-react';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
const 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'],
description: 'Convert rich text to email-safe HTML with a visual editor and instant preview.',
icon: Code2,
number: '01',
},
{
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,
number: '02',
},
{
name: 'Spam Checker',
slug: 'spam-checker',
description: 'Test your email subject line and content for spam trigger words and deliverability issues.',
icon: ShieldAlert,
number: '03',
},
{
name: 'SPF Checker',
slug: 'spf-checker',
description: 'Look up and validate your domain\'s SPF record. Catch misconfigurations before they hurt deliverability.',
icon: Shield,
number: '04',
},
{
name: 'DMARC Checker',
slug: 'dmarc-checker',
description: 'Check your DMARC policy, reporting configuration, and get step-by-step advice toward full enforcement.',
icon: ShieldCheck,
number: '05',
},
{
name: 'DKIM Checker',
slug: 'dkim-checker',
description: 'Look up your DKIM public key by selector. Verify it\'s active and correctly configured.',
icon: Key,
number: '06',
},
{
name: 'MX Record Checker',
slug: 'mx-checker',
description: 'Look up mail exchange records for any domain. Check server priority and diagnose delivery problems.',
icon: Mail,
number: '07',
},
{
name: 'Email Headers Analyzer',
slug: 'email-headers',
description: 'Paste raw email headers and get SPF/DKIM/DMARC results, routing hops, and the full authentication chain.',
icon: FileText,
number: '08',
},
];
/**
* 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."
title="Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk"
description="Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required."
canonical="https://www.useplunk.com/tools"
openGraph={{
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
title: 'Free Email Tools | SPF, DMARC, DKIM, MX Checker & More | Plunk',
description:
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
'Free email developer tools: MX record checker, email headers analyzer, SPF checker, DMARC checker, DKIM checker, spam checker, and email validator. No sign-up required.',
url: 'https://www.useplunk.com/tools',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
}}
/>
<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={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
>
<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 text-balance'}>
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`}
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={
'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'
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
}
>
<span className={'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'
}
<span className={'font-medium text-neutral-900'}>§ Tools &nbsp; &nbsp;Plunk</span>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
Browse guides
</Link>
</div>
</motion.div>
</section>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(3rem,9vw,8rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Free email
<br />
developer tools
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Check MX records, email headers, SPF, DMARC, and DKIM. Verify addresses, convert markdown to email-safe HTML, test for spam, and more. No sign-up required.
</p>
{/* 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 text-balance'}>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]}}
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer h-full'
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<div className={'flex items-start justify-between mb-4'}>
<div
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href="/guides"
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
Browse guides
</Link>
</div>
</motion.div>
</div>
</section>
{/* ========== TOOLS LIST ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'Tools'}
title={'Available tools.'}
subtitle={'Everything you need to work with email. No sign-up, no limits.'}
/>
<ul className={'mt-20 divide-y divide-neutral-200 border-y border-neutral-200'}>
{tools.map((tool, i) => (
<motion.li
key={tool.slug}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.06, ease: [0.22, 1, 0.36, 1]}}
>
<Link
href={`/tools/${tool.slug}`}
className={
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
}
>
<div className={'flex items-center gap-6 sm:gap-10'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'w-12 text-xs tabular-nums tracking-[0.18em] text-neutral-400 sm:w-16'}
>
{tool.number}
</span>
<div>
<span
style={{fontFamily: 'var(--font-display)'}}
className={
'block text-4xl font-bold tracking-[-0.03em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-5xl lg:text-6xl'
}
>
{tool.name}
</span>
<p className={'mt-2 text-sm text-neutral-500'}>{tool.description}</p>
</div>
</div>
<div className={'flex items-center gap-4'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
'hidden text-xs uppercase tracking-[0.18em] text-neutral-500 transition group-hover:text-neutral-900 sm:inline'
}
>
<Icon className="h-5 w-5" />
</div>
<Sparkles className="h-5 w-5 text-neutral-400" />
Open tool
</span>
<span
className={
'flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 text-neutral-900 transition group-hover:border-neutral-900 group-hover:bg-neutral-900 group-hover:text-white sm:h-14 sm:w-14'
}
>
<ArrowUpRight className={'h-5 w-5'} strokeWidth={2} />
</span>
</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>
))}
</Link>
</motion.li>
))}
</ul>
</section>
{/* ========== WHY ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'02'}
label={'Why'}
title={'Built for developers,'}
titleAccent={'free forever.'}
subtitle={'No sign-up, no paywalls. Tools built by email experts for real-world workflows.'}
/>
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-16'}>
{[
{
tag: 'Access',
big: '$0',
title: 'Free forever',
body: 'No sign-up, no paywalls, no limits. Use these tools as much as you need, completely free.',
},
{
tag: 'Focus',
big: 'Dev-first',
title: 'Developer-focused',
body: 'Built by developers who work with email every day. Clean outputs, instant results, real-world workflows.',
},
{
tag: 'Output',
big: '100%',
title: 'Production ready',
body: 'Email-safe HTML that works across all email clients. Industry-standard validation. Battle-tested by thousands.',
},
].map((item, i) => (
<motion.div
key={item.tag}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col gap-6'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.2em] text-neutral-500'}
>
/ {item.tag}
</span>
<div
style={{fontFamily: 'var(--font-display)'}}
className={'text-5xl font-extrabold tracking-[-0.035em] text-neutral-900 sm:text-6xl'}
>
{item.big}
</div>
<div className={'h-px w-full bg-neutral-300'} />
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-semibold text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-base leading-relaxed text-neutral-600'}>{item.body}</p>
</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 text-balance'}>Why use these tools?</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</p>
</motion.div>
<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 text-balance'}>Need production-grade email tools?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
These free tools are great for development, but Plunk offers so much more: templates, scheduling,
automation, analytics, and deliverability optimization. Start free, scale as you grow.
</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>
</div>
</motion.div>
</section>
</main>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'
}
>
Need production-grade email?
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Templates, scheduling, automation, analytics, and deliverability. Start free, scale as you grow.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Start with Plunk
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href="/pricing"
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
}
>
View pricing
</Link>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
<Footer />
</>

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