Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57bfbf874b | ||
|
|
1dc423a1c4 | ||
|
|
de8aaa5c45 | ||
|
|
1a1e3204ee | ||
|
|
6c4cc295b8 | ||
|
|
9f212ddcb8 | ||
|
|
5f82278282 | ||
|
|
f6a32ce610 | ||
|
|
0839975083 | ||
|
|
c90f3d0f01 | ||
|
|
5f3daf689b | ||
|
|
cc7fcdb4ef | ||
|
|
d75aed81e1 | ||
|
|
0cbaa8fc66 | ||
|
|
0686b3a3f2 | ||
|
|
9b15b93b96 | ||
|
|
8f2510f5ec | ||
|
|
e196d798c5 | ||
|
|
777a12ec89 | ||
|
|
6c6af775bc | ||
|
|
4f8c9f029d | ||
|
|
8f72994b46 | ||
|
|
d162f255c2 | ||
|
|
c8e252fefd | ||
|
|
b8f1ad9ab5 | ||
|
|
708ab506f1 | ||
|
|
ec1f4c9374 | ||
|
|
5528288c7b | ||
|
|
7bd098bdd0 | ||
|
|
cbea263a91 | ||
|
|
e8a247fe12 | ||
|
|
4b51e386e3 | ||
|
|
59aa7845ba | ||
|
|
5600c49bb6 | ||
|
|
21af8fe05e | ||
|
|
2c4d95e604 | ||
|
|
64bd094b47 | ||
|
|
53b33bbd36 | ||
|
|
5fa9b9dda5 | ||
|
|
4bc5424815 | ||
|
|
573e2e8449 | ||
|
|
00ae942c3c | ||
|
|
9c69c7a36e | ||
|
|
9568b7d345 | ||
|
|
6bba9b6199 | ||
|
|
2f55d32485 | ||
|
|
eec37ecb31 | ||
|
|
f947ee6f22 | ||
|
|
3330d83d08 | ||
|
|
de7ed84fcf | ||
|
|
e639c72e8b | ||
|
|
43ea660d52 | ||
|
|
1f85d448e2 | ||
|
|
e7f168c373 | ||
|
|
b42698616a | ||
|
|
a00473314f | ||
|
|
2dabcb2419 | ||
|
|
d9ae171cc7 | ||
|
|
e4e334c77a | ||
|
|
78d3d224af | ||
|
|
f9b1354460 | ||
|
|
95e5ec8485 | ||
|
|
d050b55baa | ||
|
|
7964563b62 | ||
|
|
e01dc4066c | ||
|
|
c286f49097 | ||
|
|
4c81d9ec04 | ||
|
|
a928666dfc | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
d55dda3127 | ||
|
|
d637147f25 | ||
|
|
c1ed23c967 | ||
|
|
46001619e8 | ||
|
|
7a6df7760b | ||
|
|
d2d779c68f | ||
|
|
bbc12e0029 | ||
|
|
125718bb94 | ||
|
|
35d67c6b42 | ||
|
|
14c471e4ca | ||
|
|
42ceb6edc0 | ||
|
|
5bce1d74ff | ||
|
|
b4404f698e | ||
|
|
e4625894c9 | ||
|
|
af87d80baa | ||
|
|
1f84c9113c | ||
|
|
0cf0a26a97 | ||
|
|
0d4b694208 | ||
|
|
c40394ffd6 | ||
|
|
20ffb2b8bc | ||
|
|
0af79eb84b | ||
|
|
e2e98149cb | ||
|
|
f55ab31ae5 | ||
|
|
61a65d01c7 | ||
|
|
7aa3b74c6f | ||
|
|
7647109fb6 | ||
|
|
ad0c17da56 | ||
|
|
93ca6b8d94 | ||
|
|
c85df75d53 | ||
|
|
e16093a264 | ||
|
|
a54c8139b1 | ||
|
|
8de79e37e9 | ||
|
|
c662ecdbb2 | ||
|
|
8f6f63e572 | ||
|
|
22c22aba1b | ||
|
|
de106f9d91 | ||
|
|
8a38766c02 | ||
|
|
3a16ad3221 | ||
|
|
ec402cb9b3 | ||
|
|
663bc2be8d | ||
|
|
df28e2dc7d | ||
|
|
79fe9e94c7 | ||
|
|
c2afb2dfab | ||
|
|
0d1ed0ec67 | ||
|
|
99526ebfc2 | ||
|
|
e6baace00d | ||
|
|
743155b06a | ||
|
|
0a67a8278f | ||
|
|
26800a8553 | ||
|
|
93165644af | ||
|
|
4db1ccc3fc | ||
|
|
06c4d644b7 | ||
|
|
b5fa21a57c | ||
|
|
ddbe5237fe | ||
|
|
18e17cf198 | ||
|
|
6a6772d5ed | ||
|
|
50042cea27 | ||
|
|
3ccf890a21 | ||
|
|
d57f6c81cb | ||
|
|
c07816c2a1 | ||
|
|
73da0a5136 | ||
|
|
d12b797ebe | ||
|
|
7df43d8553 |
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Default: true
|
# Default: true
|
||||||
# AUTO_PROJECT_DISABLE=false
|
# AUTO_PROJECT_DISABLE=false
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OPTIONAL: Self-Hosting User Management
|
||||||
|
# ========================================
|
||||||
|
# Controls whether new user signups are allowed
|
||||||
|
# When enabled (true), the signup endpoint will reject new user registration attempts
|
||||||
|
# Useful for private instances or when you want to manually manage users
|
||||||
|
# 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
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# ADVANCED (rarely needed)
|
# ADVANCED (rarely needed)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Feature requests
|
||||||
|
about: Use this template to suggest new features for Plunk
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Is your feature request related to a problem?
|
||||||
|
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
## Describe the solution you'd like
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
## Alternatives or workarounds
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: Product Issues
|
||||||
|
about: Use this template for bugs and issues on Plunk
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
|
||||||
|
## To Reproduce
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Please select the option that applies:
|
||||||
|
|
||||||
|
* Deployment type:
|
||||||
|
|
||||||
|
* [ ] Hosted
|
||||||
|
* [ ] Self-hosted
|
||||||
|
|
||||||
|
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
|
||||||
|
|
||||||
|
|
||||||
|
## Version verification
|
||||||
|
|
||||||
|
> ⚠️ **Important:**
|
||||||
|
> Self-hosted issues **must** be reproduced on the latest commit.
|
||||||
|
> Issues without a confirmed commit SHA may be closed without investigation.
|
||||||
|
|
||||||
|
* [ ] I am using the hosted version
|
||||||
|
|
||||||
|
**If self-hosted:**
|
||||||
|
|
||||||
|
* [ ] I have confirmed this issue still exists on the **latest commit** (not the `latest` tag)
|
||||||
|
|
||||||
|
* Commit SHA tested: `__________`
|
||||||
|
|
||||||
|
## Logs / Error output
|
||||||
|
|
||||||
|
If applicable, add logs, stack traces, or error messages here.
|
||||||
|
|
||||||
|
```
|
||||||
|
PASTE LOGS HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots / Recordings / Additional context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Please confirm the following before submitting:
|
||||||
|
|
||||||
|
* [ ] I have searched existing issues to ensure this bug has not already been reported
|
||||||
|
* [ ] I have provided clear reproduction steps
|
||||||
|
* [ ] I have included all relevant environment details
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.4.0"
|
".": "0.7.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ nodeLinker: node-modules
|
|||||||
|
|
||||||
# Support multiple architectures - download pre-built binaries instead of compiling
|
# Support multiple architectures - download pre-built binaries instead of compiling
|
||||||
supportedArchitectures:
|
supportedArchitectures:
|
||||||
os: [ "linux", "darwin" ]
|
os: [ "linux", "darwin", "win32" ]
|
||||||
cpu: [ "x64", "arm64" ]
|
cpu: [ "x64", "arm64" ]
|
||||||
libc: [ "glibc" ]
|
libc: [ "glibc" ]
|
||||||
|
|
||||||
|
|||||||
+108
@@ -1,5 +1,113 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
|
||||||
|
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
|
||||||
|
|
||||||
|
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||||
|
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||||
|
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||||
|
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||||
|
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||||
|
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||||
|
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||||
|
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||||
|
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||||
|
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||||
|
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||||
|
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
|
||||||
|
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
|
||||||
|
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
|
||||||
|
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
|
||||||
|
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
|
||||||
|
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
|
||||||
|
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
|
||||||
|
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
|
||||||
|
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
|
||||||
|
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
|
||||||
|
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
|
||||||
|
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
|
||||||
|
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
|
||||||
|
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
|
||||||
|
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||||
|
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
|
||||||
|
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
|
||||||
|
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
|
||||||
|
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
|
||||||
|
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
|
||||||
|
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
|
||||||
|
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
|
||||||
|
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
|
||||||
|
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
|
||||||
|
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
|
||||||
|
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
|
||||||
|
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
|
||||||
|
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
|
||||||
|
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
|
||||||
|
|
||||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
|||||||
### Applications (`apps/`)
|
### Applications (`apps/`)
|
||||||
|
|
||||||
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
||||||
- HTTP API endpoints for the platform
|
- HTTP API endpoints for the platform
|
||||||
- Background cron jobs (workflow processor, domain verification)
|
- Background cron jobs (workflow processor, domain verification)
|
||||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||||
@@ -148,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||||
|
- Self-hosting User Management (optional):
|
||||||
|
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
|
||||||
|
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
|
||||||
|
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
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
@@ -159,7 +165,8 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
client-side access
|
client-side access
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
There are two plugins installed for you to use.
|
There are two plugins installed for you to use.
|
||||||
|
|
||||||
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
||||||
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
|
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
|||||||
USE_HTTPS=false
|
USE_HTTPS=false
|
||||||
|
|
||||||
API_URI=http://localhost:8080
|
API_URI=http://localhost:8080
|
||||||
|
WIKI_URI=http://localhost:1000
|
||||||
DASHBOARD_URI=http://localhost:3000
|
DASHBOARD_URI=http://localhost:3000
|
||||||
LANDING_URI=http://localhost:4000
|
LANDING_URI=http://localhost:4000
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailchecker": "^6.0.19",
|
"mailchecker": "^6.0.19",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.1",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
"stripe": "^20.0.0"
|
"stripe": "^20.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
|
|||||||
expect(result.data.subscribed).toBeUndefined();
|
expect(result.data.subscribed).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
|
||||||
|
const newEmail = '[email protected]';
|
||||||
|
|
||||||
|
// Send email to new contact without specifying subscribed
|
||||||
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
|
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
|
||||||
|
|
||||||
|
// Transactional emails should create contacts as unsubscribed by default
|
||||||
|
expect(contact.subscribed).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/v1/track endpoint', () => {
|
describe('/v1/track endpoint', () => {
|
||||||
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
|
|||||||
|
|
||||||
// Track event for new contact without specifying subscribed
|
// Track event for new contact without specifying subscribed
|
||||||
const {ContactService} = await import('../../services/ContactService.js');
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
|
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
|
||||||
|
|
||||||
// New contacts should default to subscribed=true
|
// Event tracking should create contacts as subscribed by default
|
||||||
expect(contact.subscribed).toBe(true);
|
expect(contact.subscribed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
|||||||
// Useful for self-hosters who want to manage project status manually
|
// Useful for self-hosters who want to manage project status manually
|
||||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||||
|
|
||||||
|
// Self-hosting Configuration (optional)
|
||||||
|
// Controls whether new user signups are allowed (default: false)
|
||||||
|
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';
|
||||||
|
|
||||||
// Email Verification & Password Reset
|
// Email Verification & Password Reset
|
||||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export class Actions {
|
|||||||
|
|
||||||
// Create or update contact with persistent data only
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
|
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||||
|
// but preserve existing subscription state for existing contacts
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
auth.projectId,
|
auth.projectId,
|
||||||
email,
|
email,
|
||||||
@@ -266,7 +268,10 @@ export class Actions {
|
|||||||
: (data as Record<string, unknown> | undefined);
|
: (data as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
// Create or update contact with metadata
|
// Create or update contact with metadata
|
||||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
|
// Transactional emails should not subscribe contacts by default
|
||||||
|
// New contacts default to unsubscribed unless explicitly opted in
|
||||||
|
// Existing contacts preserve their subscription state unless explicitly changed
|
||||||
|
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
|
||||||
|
|
||||||
// Get merged data including non-persistent fields for template rendering
|
// Get merged data including non-persistent fields for template rendering
|
||||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
PASSWORD_RESET_RATE_LIMIT,
|
PASSWORD_RESET_RATE_LIMIT,
|
||||||
PLUNK_ENABLED,
|
PLUNK_ENABLED,
|
||||||
TOKEN_EXPIRY_SECONDS,
|
TOKEN_EXPIRY_SECONDS,
|
||||||
|
VERIFY_EMAIL_ON_SIGNUP,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||||
@@ -63,33 +65,43 @@ export class Auth {
|
|||||||
@Post('signup')
|
@Post('signup')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
public async signup(req: Request, res: Response, _next: NextFunction) {
|
||||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
// Verify email is valid and not disposable/plus-addressed
|
|
||||||
const verification = await EmailVerificationService.verifyEmail(email);
|
|
||||||
|
|
||||||
if (
|
|
||||||
verification.isDisposable ||
|
|
||||||
verification.isPlusAddressed ||
|
|
||||||
!verification.domainExists ||
|
|
||||||
!verification.hasMxRecords
|
|
||||||
) {
|
|
||||||
// Build list of reasons for notification
|
|
||||||
const reasons: string[] = [];
|
|
||||||
if (verification.isDisposable) reasons.push('disposable email');
|
|
||||||
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
|
||||||
if (!verification.domainExists) reasons.push('domain does not exist');
|
|
||||||
if (!verification.hasMxRecords) reasons.push('no MX records');
|
|
||||||
|
|
||||||
// Send notification about failed signup attempt
|
|
||||||
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
data: 'This email address cannot be used for signup',
|
data: 'New user signups are currently disabled',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||||
|
|
||||||
|
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
|
||||||
|
if (VERIFY_EMAIL_ON_SIGNUP) {
|
||||||
|
const verification = await EmailVerificationService.verifyEmail(email);
|
||||||
|
|
||||||
|
if (
|
||||||
|
verification.isDisposable ||
|
||||||
|
verification.isPlusAddressed ||
|
||||||
|
!verification.domainExists ||
|
||||||
|
!verification.hasMxRecords
|
||||||
|
) {
|
||||||
|
// Build list of reasons for notification
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (verification.isDisposable) reasons.push('disposable email');
|
||||||
|
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
||||||
|
if (!verification.domainExists) reasons.push('domain does not exist');
|
||||||
|
if (!verification.hasMxRecords) reasons.push('no MX records');
|
||||||
|
|
||||||
|
// Send notification about failed signup attempt
|
||||||
|
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
data: 'This email address cannot be used for signup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await UserService.email(email);
|
const user = await UserService.email(email);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
|
AWS_SES_REGION,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
@@ -59,6 +60,9 @@ export class Config {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
aws: {
|
||||||
|
sesRegion: AWS_SES_REGION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -322,7 +323,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getImportJobStatus(jobId);
|
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Import job not found'});
|
return res.status(404).json({error: 'Import job not found'});
|
||||||
@@ -502,6 +503,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -509,7 +511,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getBulkActionJobStatus(jobId);
|
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Bulk action job not found'});
|
return res.status(404).json({error: 'Bulk action job not found'});
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GITHUB_OAUTH_CLIENT,
|
GITHUB_OAUTH_CLIENT,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GITHUB_OAUTH_SECRET,
|
GITHUB_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -81,6 +83,11 @@ export class Github {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GOOGLE_OAUTH_CLIENT,
|
GOOGLE_OAUTH_CLIENT,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_SECRET,
|
GOOGLE_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -71,6 +73,11 @@ export class Google {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -72,20 +72,23 @@ export class Segments {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {name, description, condition, trackMembership} = req.body;
|
const {name, description, type, condition, trackMembership} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).json({error: 'Name is required'});
|
return res.status(400).json({error: 'Name is required'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!condition || typeof condition !== 'object') {
|
const segmentType = type ?? 'DYNAMIC';
|
||||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
|
||||||
|
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||||
|
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const segment = await SegmentService.create(auth.projectId!, {
|
const segment = await SegmentService.create(auth.projectId!, {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,6 +145,56 @@ export class Segments {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /segments/:id/members
|
||||||
|
* Add contacts to a static segment by email
|
||||||
|
*/
|
||||||
|
@Post(':id/members')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const segmentId = req.params.id;
|
||||||
|
const {emails} = req.body;
|
||||||
|
|
||||||
|
if (!segmentId) {
|
||||||
|
return res.status(400).json({error: 'Segment ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) {
|
||||||
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /segments/:id/members
|
||||||
|
* Remove contacts from a static segment by email
|
||||||
|
*/
|
||||||
|
@Delete(':id/members')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const segmentId = req.params.id;
|
||||||
|
const {emails} = req.body;
|
||||||
|
|
||||||
|
if (!segmentId) {
|
||||||
|
return res.status(400).json({error: 'Segment ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(emails) || emails.length === 0) {
|
||||||
|
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||||
|
|
||||||
|
return res.status(200).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /segments/:id/compute
|
* POST /segments/:id/compute
|
||||||
* Recompute segment membership for all contacts
|
* Recompute segment membership for all contacts
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
|||||||
import * as S3Service from '../services/S3Service.js';
|
import * as S3Service from '../services/S3Service.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const MAGIC_BYTES: Record<string, Buffer[]> = {
|
||||||
|
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||||
|
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||||
|
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
|
||||||
|
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
|
||||||
|
'image/webp': [Buffer.from('RIFF')],
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
|
||||||
|
const signatures = MAGIC_BYTES[mimetype];
|
||||||
|
if (!signatures) return false;
|
||||||
|
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
|
||||||
|
}
|
||||||
|
|
||||||
// Configure multer for file uploads (memory storage)
|
// Configure multer for file uploads (memory storage)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
@@ -13,12 +27,12 @@ const upload = multer({
|
|||||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||||
},
|
},
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
|
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,6 +62,12 @@ export class Uploads {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'File contents do not match the declared image type',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Upload file to S3/Minio
|
// Upload file to S3/Minio
|
||||||
const result = await S3Service.uploadFile({
|
const result = await S3Service.uploadFile({
|
||||||
file: req.file.buffer,
|
file: req.file.buffer,
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,6 +350,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: data.workflows,
|
billingLimitWorkflows: data.workflows,
|
||||||
billingLimitCampaigns: data.campaigns,
|
billingLimitCampaigns: data.campaigns,
|
||||||
billingLimitTransactional: data.transactional,
|
billingLimitTransactional: data.transactional,
|
||||||
|
billingLimitInbound: data.inbound,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,6 +362,7 @@ export class Users {
|
|||||||
workflows: project.billingLimitWorkflows,
|
workflows: project.billingLimitWorkflows,
|
||||||
campaigns: project.billingLimitCampaigns,
|
campaigns: project.billingLimitCampaigns,
|
||||||
transactional: project.billingLimitTransactional,
|
transactional: project.billingLimitTransactional,
|
||||||
|
inbound: project.billingLimitInbound,
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Post} from '@overnightjs/core';
|
import {Controller, Post} from '@overnightjs/core';
|
||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import type {Request, Response} from 'express';
|
import type {Request, Response} from 'express';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
@@ -8,7 +8,10 @@ import type Stripe from 'stripe';
|
|||||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -21,7 +24,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
|
|||||||
export class Webhooks {
|
export class Webhooks {
|
||||||
/**
|
/**
|
||||||
* Receive SNS webhook notifications from AWS SES
|
* Receive SNS webhook notifications from AWS SES
|
||||||
* Handles email events: delivery, open, click, bounce, complaint
|
* Handles outbound email events: delivery, open, click, bounce, complaint
|
||||||
|
* Handles inbound email notifications: received emails via SES receiving
|
||||||
*/
|
*/
|
||||||
@Post('sns')
|
@Post('sns')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
@@ -30,11 +34,35 @@ export class Webhooks {
|
|||||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||||
signale.info('SNS Subscription Confirmation received');
|
signale.info('SNS Subscription Confirmation received');
|
||||||
signale.info('Subscribe URL:', req.body.SubscribeURL);
|
|
||||||
|
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
|
||||||
|
// Legitimate URLs look like:
|
||||||
|
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
|
||||||
|
const subscribeURL: unknown = req.body.SubscribeURL;
|
||||||
|
if (typeof subscribeURL !== 'string') {
|
||||||
|
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
|
||||||
|
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedURL: URL;
|
||||||
|
try {
|
||||||
|
parsedURL = new URL(subscribeURL);
|
||||||
|
} catch {
|
||||||
|
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
|
||||||
|
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||||
|
// The hostname must be exactly sns.<region>.amazonaws.com.
|
||||||
|
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||||
|
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||||
|
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||||
|
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||||
|
}
|
||||||
|
|
||||||
// Automatically confirm the subscription
|
// Automatically confirm the subscription
|
||||||
try {
|
try {
|
||||||
const confirmResponse = await fetch(req.body.SubscribeURL);
|
const confirmResponse = await fetch(subscribeURL);
|
||||||
if (confirmResponse.ok) {
|
if (confirmResponse.ok) {
|
||||||
signale.success('SNS subscription confirmed successfully');
|
signale.success('SNS subscription confirmed successfully');
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
@@ -46,7 +74,6 @@ export class Webhooks {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Failed to confirm subscription',
|
message: 'Failed to confirm subscription',
|
||||||
subscribeURL: req.body.SubscribeURL,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (confirmError) {
|
} catch (confirmError) {
|
||||||
@@ -54,7 +81,6 @@ export class Webhooks {
|
|||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Error confirming subscription',
|
message: 'Error confirming subscription',
|
||||||
subscribeURL: req.body.SubscribeURL,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,6 +93,150 @@ export class Webhooks {
|
|||||||
|
|
||||||
// Parse the nested SES event from the Message field
|
// Parse the nested SES event from the Message field
|
||||||
const body = JSON.parse(req.body.Message);
|
const body = JSON.parse(req.body.Message);
|
||||||
|
|
||||||
|
// Check if this is an inbound email notification (SES Receiving)
|
||||||
|
if (body.notificationType === 'Received') {
|
||||||
|
signale.info('[WEBHOOK] Received inbound email notification from SES');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract recipient addresses from the inbound email
|
||||||
|
const recipients = body.receipt?.recipients || [];
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
signale.warn('[WEBHOOK] No recipients found in inbound email');
|
||||||
|
return res.status(200).json({success: true, message: 'No recipients found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each recipient, identify the domain and create events
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const recipientEmail = recipient as string;
|
||||||
|
const domain = recipientEmail.split('@')[1];
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ALL projects that have this domain verified
|
||||||
|
// A domain can be shared across multiple projects if users are members of both
|
||||||
|
const domainRecords = await prisma.domain.findMany({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
verified: true, // Only process emails for verified domains
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domainRecords.length === 0) {
|
||||||
|
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract sender information (same for all projects)
|
||||||
|
const senderEmail = body.mail?.source;
|
||||||
|
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
|
||||||
|
// Check billing limits before processing inbound email
|
||||||
|
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||||
|
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
signale.warn(
|
||||||
|
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
|
||||||
|
);
|
||||||
|
continue; // Skip this project but continue processing for other projects
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create a contact for the sender in this project
|
||||||
|
let contact;
|
||||||
|
if (senderEmail) {
|
||||||
|
contact = await ContactService.upsert(
|
||||||
|
domainRecord.projectId,
|
||||||
|
senderEmail,
|
||||||
|
undefined, // No additional data
|
||||||
|
true, // Subscribe by default for inbound email senders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an Email record for tracking (no actual email content since it's inbound)
|
||||||
|
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
|
||||||
|
from: recipientEmail, // The recipient address that received the email
|
||||||
|
sourceType: EmailSourceType.INBOUND,
|
||||||
|
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||||
|
deliveredAt: new Date(body.mail?.timestamp || new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment usage counter in cache
|
||||||
|
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||||
|
|
||||||
|
// Record Stripe metering if project has customer
|
||||||
|
if (domainRecord.project.customer) {
|
||||||
|
await MeterService.recordEmailSent(
|
||||||
|
domainRecord.project.customer,
|
||||||
|
1, // Inbound emails count as 1 credit
|
||||||
|
`email_${inboundEmail.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare event data with all inbound email details
|
||||||
|
const eventData = {
|
||||||
|
messageId: body.mail?.messageId,
|
||||||
|
from: senderEmail,
|
||||||
|
fromHeader: senderFromHeader,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: body.mail?.commonHeaders?.subject,
|
||||||
|
timestamp: body.mail?.timestamp,
|
||||||
|
recipients: body.receipt?.recipients,
|
||||||
|
hasContent: !!body.content,
|
||||||
|
// Security verdicts
|
||||||
|
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||||
|
virusVerdict: body.receipt?.virusVerdict?.status,
|
||||||
|
spfVerdict: body.receipt?.spfVerdict?.status,
|
||||||
|
dkimVerdict: body.receipt?.dkimVerdict?.status,
|
||||||
|
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
|
||||||
|
// Processing metadata
|
||||||
|
processingTimeMillis: body.receipt?.processingTimeMillis,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the email.received event (this will trigger workflows)
|
||||||
|
await EventService.trackEvent(
|
||||||
|
domainRecord.projectId,
|
||||||
|
'email.received',
|
||||||
|
contact?.id,
|
||||||
|
inboundEmail.id, // Link the event to the inbound email record
|
||||||
|
eventData,
|
||||||
|
);
|
||||||
|
|
||||||
|
signale.success(
|
||||||
|
`[WEBHOOK] Created email.received event for ${senderEmail} → ${recipientEmail} (project: ${domainRecord.project.name})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({success: true, message: 'Inbound email processed'});
|
||||||
|
} catch (inboundError) {
|
||||||
|
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
|
||||||
|
// Return 200 to acknowledge receipt even if processing failed
|
||||||
|
return res.status(200).json({success: true, message: 'Error processing inbound email'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle outbound email event notifications (existing logic)
|
||||||
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
||||||
const messageId = body.mail?.messageId;
|
const messageId = body.mail?.messageId;
|
||||||
|
|
||||||
@@ -152,29 +322,62 @@ export class Webhooks {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Bounce':
|
case 'Bounce': {
|
||||||
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
|
const bounceType = body.bounce?.bounceType;
|
||||||
updateData.status = EmailStatus.BOUNCED;
|
const isPermanentBounce = bounceType === 'Permanent';
|
||||||
updateData.bouncedAt = now;
|
const isTransientBounce = bounceType === 'Transient';
|
||||||
// Unsubscribe contact on bounce
|
|
||||||
await prisma.contact.update({
|
|
||||||
where: {id: email.contactId},
|
|
||||||
data: {subscribed: false},
|
|
||||||
});
|
|
||||||
eventData = {
|
|
||||||
...baseEventData,
|
|
||||||
bounceType: body.bounce?.bounceType,
|
|
||||||
bouncedAt: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send notification about bounce
|
if (isPermanentBounce) {
|
||||||
await NtfyService.notifyEmailBounce(
|
// Hard bounce - counts toward bounce rate and unsubscribes contact
|
||||||
email.project.name,
|
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||||
email.projectId,
|
updateData.status = EmailStatus.BOUNCED;
|
||||||
email.contact.email,
|
updateData.bouncedAt = now;
|
||||||
body.bounce?.bounceType,
|
// Unsubscribe contact on permanent bounce
|
||||||
);
|
await prisma.contact.update({
|
||||||
|
where: {id: email.contactId},
|
||||||
|
data: {subscribed: false},
|
||||||
|
});
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
bouncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send notification about permanent bounce
|
||||||
|
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||||
|
} else if (isTransientBounce) {
|
||||||
|
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
|
||||||
|
signale.info(
|
||||||
|
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
|
||||||
|
);
|
||||||
|
// Don't update email status or unsubscribe contact
|
||||||
|
// Just track the event for visibility
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
transientBounce: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Unknown bounce type - treat as permanent to be safe
|
||||||
|
signale.warn(
|
||||||
|
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
|
||||||
|
);
|
||||||
|
updateData.status = EmailStatus.BOUNCED;
|
||||||
|
updateData.bouncedAt = now;
|
||||||
|
await prisma.contact.update({
|
||||||
|
where: {id: email.contactId},
|
||||||
|
data: {subscribed: false},
|
||||||
|
});
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
bouncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'Complaint':
|
case 'Complaint':
|
||||||
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
||||||
@@ -208,8 +411,10 @@ export class Webhooks {
|
|||||||
// Track event (this will trigger workflows)
|
// Track event (this will trigger workflows)
|
||||||
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
||||||
|
|
||||||
// Check security limits for bounce and complaint events
|
// Check security limits only for permanent bounces and complaints
|
||||||
if (eventType === 'Bounce' || eventType === 'Complaint') {
|
// Transient bounces (soft bounces) don't count toward bounce rate
|
||||||
|
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
|
||||||
|
if (isPermanentBounce || eventType === 'Complaint') {
|
||||||
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ export async function checkDomainVerifications() {
|
|||||||
|
|
||||||
// Send email notification about domain verified
|
// Send email notification about domain verified
|
||||||
try {
|
try {
|
||||||
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const ttl = 604800; // 7 days
|
||||||
if (alreadySent !== '1') {
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -138,7 +142,6 @@ export async function checkDomainVerifications() {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||||
);
|
);
|
||||||
await redis.setex(cacheKey, 604800, '1'); // 7 days
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,12 +159,17 @@ export async function checkDomainVerifications() {
|
|||||||
|
|
||||||
// Send email notification about domain verification failed
|
// Send email notification about domain verification failed
|
||||||
try {
|
try {
|
||||||
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
if (alreadySent !== '1') {
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -175,9 +183,6 @@ export async function checkDomainVerifications() {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||||
);
|
);
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -117,10 +117,25 @@ export async function createEmailWorker() {
|
|||||||
const fromName = email.fromName || email.project.name;
|
const fromName = email.fromName || email.project.name;
|
||||||
const fromEmail = email.from;
|
const fromEmail = email.from;
|
||||||
|
|
||||||
|
// Parse custom headers from JSON
|
||||||
|
const customHeaders =
|
||||||
|
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
|
||||||
|
? (email.headers as Record<string, string>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Check for custom recipient override in headers
|
||||||
|
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||||
|
|
||||||
|
// Remove internal headers before sending
|
||||||
|
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||||
|
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||||
|
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||||
|
}
|
||||||
|
|
||||||
// Build recipient with name if available
|
// Build recipient with name if available
|
||||||
const recipient: {name?: string; email: string} | string = email.toName
|
const recipient: {name?: string; email: string} | string = email.toName
|
||||||
? {name: email.toName, email: email.contact.email}
|
? {name: email.toName, email: recipientEmail}
|
||||||
: email.contact.email;
|
: recipientEmail;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||||
@@ -137,6 +152,7 @@ export async function createEmailWorker() {
|
|||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
|
headers: publicHeaders,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
|
|||||||
* - For segments without trackMembership: only update counts
|
* - For segments without trackMembership: only update counts
|
||||||
*/
|
*/
|
||||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||||
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
|
|
||||||
|
|
||||||
// Get all segments for this project, separating tracked vs non-tracked
|
// Get all segments for this project, separating tracked vs non-tracked
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
@@ -29,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process tracked segments with full membership computation (creates events)
|
// Process tracked segments with full membership computation (creates events)
|
||||||
|
let updatedSegmentCount = 0;
|
||||||
|
let totalAdded = 0;
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
if (trackedSegments.length > 0) {
|
if (trackedSegments.length > 0) {
|
||||||
for (const segment of trackedSegments) {
|
for (const segment of trackedSegments) {
|
||||||
try {
|
try {
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
|
||||||
);
|
|
||||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||||
signale.success(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify about segment membership update only if there were actual changes
|
// Track segments with actual changes for bundled notification
|
||||||
if (projectName && (result.added > 0 || result.removed > 0)) {
|
if (result.added > 0 || result.removed > 0) {
|
||||||
await NtfyService.notifySegmentMembershipComputed(
|
updatedSegmentCount++;
|
||||||
segment.name,
|
totalAdded += result.added;
|
||||||
projectName,
|
totalRemoved += result.removed;
|
||||||
projectId,
|
|
||||||
result.total,
|
|
||||||
result.added,
|
|
||||||
result.removed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||||
// Continue with other segments
|
// Continue with other segments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send bundled notification if there were any changes
|
||||||
|
if (projectName && updatedSegmentCount > 0) {
|
||||||
|
await NtfyService.notifySegmentMembershipBundled(
|
||||||
|
projectName,
|
||||||
|
projectId,
|
||||||
|
updatedSegmentCount,
|
||||||
|
totalAdded,
|
||||||
|
totalRemoved,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process non-tracked segments with count-only update (lightweight)
|
// Process non-tracked segments with count-only update (lightweight)
|
||||||
if (nonTrackedSegments.length > 0) {
|
if (nonTrackedSegments.length > 0) {
|
||||||
try {
|
try {
|
||||||
await SegmentService.refreshAllMemberCounts(projectId);
|
await SegmentService.refreshAllMemberCounts(projectId);
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||||
}
|
}
|
||||||
@@ -80,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||||
const {projectId} = job.data;
|
const {projectId} = job.data;
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Process specific project
|
// Process specific project
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
|
||||||
await processProjectSegments(projectId);
|
await processProjectSegments(projectId);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
|
||||||
} else {
|
} else {
|
||||||
// Process all active projects
|
// Process all active projects
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
@@ -95,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
select: {id: true, name: true},
|
select: {id: true, name: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||||
|
|
||||||
// Process projects in batches to avoid overwhelming the database
|
// Process projects in batches to avoid overwhelming the database
|
||||||
const PROJECT_BATCH_SIZE = 10;
|
const PROJECT_BATCH_SIZE = 10;
|
||||||
@@ -105,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async project => {
|
batch.map(async project => {
|
||||||
try {
|
try {
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
|
||||||
await processProjectSegments(project.id, project.name);
|
await processProjectSegments(project.id, project.name);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||||
// Don't throw - continue with other projects
|
// Don't throw - continue with other projects
|
||||||
@@ -120,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||||
@@ -148,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
worker.on('failed', (job, error) => {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -366,15 +366,41 @@ export class ActivityService {
|
|||||||
...(contactId ? {contactId} : {}),
|
...(contactId ? {contactId} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build OR conditions to filter by the appropriate timestamp field for each activity type
|
||||||
|
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
|
||||||
|
const orConditions: Prisma.EmailWhereInput[] = [];
|
||||||
|
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
|
||||||
|
orConditions.push({sentAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
|
||||||
|
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_RECEIVED)) {
|
||||||
|
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
|
||||||
|
orConditions.push({openedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
|
||||||
|
orConditions.push({clickedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
|
||||||
|
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
|
||||||
|
orConditions.push({complainedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no OR conditions, return empty (shouldn't happen but defensive)
|
||||||
|
if (orConditions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const emails = await prisma.email.findMany({
|
const emails = await prisma.email.findMany({
|
||||||
where: {
|
where: {
|
||||||
...where,
|
...where,
|
||||||
createdAt: cursorTimestamp
|
OR: orConditions,
|
||||||
? {
|
|
||||||
...dateFilter,
|
|
||||||
lt: cursorTimestamp,
|
|
||||||
}
|
|
||||||
: dateFilter,
|
|
||||||
},
|
},
|
||||||
orderBy: {createdAt: 'desc'},
|
orderBy: {createdAt: 'desc'},
|
||||||
take: limit,
|
take: limit,
|
||||||
@@ -401,6 +427,27 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if timestamp is within date range
|
||||||
|
const isInDateRange = (timestamp: Date | null) => {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
|
||||||
|
// Check against date filter
|
||||||
|
const time = timestamp.getTime();
|
||||||
|
if (dateFilter.gte) {
|
||||||
|
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
|
||||||
|
if (time < gteTime) return false;
|
||||||
|
}
|
||||||
|
if (dateFilter.lte) {
|
||||||
|
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
|
||||||
|
if (time > lteTime) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against cursor for pagination
|
||||||
|
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// Convert each email into multiple activities based on its state
|
// Convert each email into multiple activities based on its state
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const baseMetadata = {
|
const baseMetadata = {
|
||||||
@@ -415,7 +462,7 @@ export class ActivityService {
|
|||||||
workflowName: email.workflowExecution?.workflow?.name,
|
workflowName: email.workflowExecution?.workflow?.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
|
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_sent`,
|
id: `${email.id}_sent`,
|
||||||
type: ActivityType.EMAIL_SENT,
|
type: ActivityType.EMAIL_SENT,
|
||||||
@@ -426,7 +473,11 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
|
if (
|
||||||
|
email.deliveredAt &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
|
||||||
|
isInDateRange(email.deliveredAt)
|
||||||
|
) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_delivered`,
|
id: `${email.id}_delivered`,
|
||||||
type: ActivityType.EMAIL_DELIVERED,
|
type: ActivityType.EMAIL_DELIVERED,
|
||||||
@@ -437,7 +488,23 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
|
if (
|
||||||
|
email.deliveredAt &&
|
||||||
|
email.sourceType === 'INBOUND' &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
|
||||||
|
isInDateRange(email.deliveredAt)
|
||||||
|
) {
|
||||||
|
activities.push({
|
||||||
|
id: `${email.id}_received`,
|
||||||
|
type: ActivityType.EMAIL_RECEIVED,
|
||||||
|
timestamp: email.deliveredAt,
|
||||||
|
contactEmail: email.contact.email,
|
||||||
|
contactId: email.contactId,
|
||||||
|
metadata: baseMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_opened`,
|
id: `${email.id}_opened`,
|
||||||
type: ActivityType.EMAIL_OPENED,
|
type: ActivityType.EMAIL_OPENED,
|
||||||
@@ -452,7 +519,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email clicked
|
// Email clicked
|
||||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
|
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_clicked`,
|
id: `${email.id}_clicked`,
|
||||||
type: ActivityType.EMAIL_CLICKED,
|
type: ActivityType.EMAIL_CLICKED,
|
||||||
@@ -467,7 +534,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email bounced
|
// Email bounced
|
||||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
|
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_bounced`,
|
id: `${email.id}_bounced`,
|
||||||
type: ActivityType.EMAIL_BOUNCED,
|
type: ActivityType.EMAIL_BOUNCED,
|
||||||
@@ -480,6 +547,25 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email complaint
|
||||||
|
if (
|
||||||
|
email.complainedAt &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
|
||||||
|
isInDateRange(email.complainedAt)
|
||||||
|
) {
|
||||||
|
activities.push({
|
||||||
|
id: `${email.id}_complaint`,
|
||||||
|
type: ActivityType.EMAIL_COMPLAINT,
|
||||||
|
timestamp: email.complainedAt,
|
||||||
|
contactEmail: email.contact.email,
|
||||||
|
contactId: email.contactId,
|
||||||
|
metadata: {
|
||||||
|
...baseMetadata,
|
||||||
|
error: email.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +184,9 @@ export class BillingLimitService {
|
|||||||
case EmailSourceType.TRANSACTIONAL:
|
case EmailSourceType.TRANSACTIONAL:
|
||||||
limit = project.billingLimitTransactional;
|
limit = project.billingLimitTransactional;
|
||||||
break;
|
break;
|
||||||
|
case EmailSourceType.INBOUND:
|
||||||
|
limit = project.billingLimitInbound;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
limit = null;
|
limit = null;
|
||||||
}
|
}
|
||||||
@@ -191,7 +195,8 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||||
// Only enforce free tier limits if billing is enabled
|
// Only enforce free tier limits if billing is enabled
|
||||||
@@ -368,6 +373,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,10 +396,11 @@ export class BillingLimitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get usage for all categories in parallel
|
// Get usage for all categories in parallel
|
||||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Helper to calculate category usage
|
// Helper to calculate category usage
|
||||||
@@ -412,18 +419,19 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
// Only show free tier limits if billing is enabled
|
// Only show free tier limits if billing is enabled
|
||||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||||
const isBlocked = totalUsage >= limit;
|
const isBlocked = totalUsage >= limit;
|
||||||
|
|
||||||
// For free tier, show the same limit and total usage for all three categories
|
// For free tier, show the same limit and total usage for all four categories
|
||||||
// This makes it clear in the UI that it's a shared limit
|
// This makes it clear in the UI that it's a shared limit
|
||||||
const sharedUsageInfo: CategoryUsage = {
|
const sharedUsageInfo: CategoryUsage = {
|
||||||
limit,
|
limit,
|
||||||
@@ -437,6 +445,7 @@ export class BillingLimitService {
|
|||||||
workflows: sharedUsageInfo,
|
workflows: sharedUsageInfo,
|
||||||
campaigns: sharedUsageInfo,
|
campaigns: sharedUsageInfo,
|
||||||
transactional: sharedUsageInfo,
|
transactional: sharedUsageInfo,
|
||||||
|
inbound: sharedUsageInfo,
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -446,6 +455,7 @@ export class BillingLimitService {
|
|||||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||||
|
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -470,6 +480,7 @@ export class BillingLimitService {
|
|||||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(keys.map(key => redis.del(key)));
|
await Promise.all(keys.map(key => redis.del(key)));
|
||||||
@@ -493,11 +504,13 @@ export class BillingLimitService {
|
|||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
newLimits: {
|
newLimits: {
|
||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -531,6 +544,14 @@ export class BillingLimitService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check inbound limit
|
||||||
|
if (oldLimits.inbound !== newLimits.inbound) {
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||||
|
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (keysToDelete.length > 0) {
|
if (keysToDelete.length > 0) {
|
||||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||||
signale.debug(
|
signale.debug(
|
||||||
@@ -580,8 +601,15 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
|
// Email was already sent this month
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,11 +631,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||||
|
|
||||||
// Mark that we've sent the warning email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||||
}
|
}
|
||||||
@@ -630,8 +653,15 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
|
// Email was already sent this month
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,11 +682,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||||
|
|
||||||
// Mark that we've sent the limit email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -760,6 +760,18 @@ export class CampaignService {
|
|||||||
throw new HttpException(404, 'Segment not found');
|
throw new HttpException(404, 'Segment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
return {
|
||||||
|
...baseWhere,
|
||||||
|
segmentMemberships: {
|
||||||
|
some: {
|
||||||
|
segmentId,
|
||||||
|
exitedAt: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {type Contact, Prisma} from '@plunk/db';
|
import {type Contact, Prisma} from '@plunk/db';
|
||||||
import {isValidLanguageCode} from '@plunk/shared';
|
|
||||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
import {toPrismaJson} from '@plunk/types';
|
import {toPrismaJson} from '@plunk/types';
|
||||||
|
|
||||||
@@ -199,6 +198,7 @@ export class ContactService {
|
|||||||
email: string,
|
email: string,
|
||||||
data?: Record<string, unknown>,
|
data?: Record<string, unknown>,
|
||||||
subscribed?: boolean,
|
subscribed?: boolean,
|
||||||
|
defaultSubscribed: boolean = true,
|
||||||
): Promise<Contact> {
|
): Promise<Contact> {
|
||||||
// Find existing contact
|
// Find existing contact
|
||||||
const existing = await prisma.contact.findFirst({
|
const existing = await prisma.contact.findFirst({
|
||||||
@@ -235,12 +235,9 @@ export class ContactService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate locale field (special user-settable field)
|
// 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 (key === 'locale') {
|
||||||
if (typeof value === 'string') {
|
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||||
if (!isValidLanguageCode(value)) {
|
|
||||||
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
|
|
||||||
}
|
|
||||||
} else if (value !== null && value !== undefined) {
|
|
||||||
throw new HttpException(400, 'Locale must be a string');
|
throw new HttpException(400, 'Locale must be a string');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,7 +289,7 @@ export class ContactService {
|
|||||||
projectId,
|
projectId,
|
||||||
email,
|
email,
|
||||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||||
subscribed: subscribed ?? true,
|
subscribed: subscribed ?? defaultSubscribed,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {HttpException} from '../exceptions/index.js';
|
|||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
import {MembershipService} from './MembershipService.js';
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {
|
||||||
|
deleteIdentity,
|
||||||
|
disableFeedbackForwarding,
|
||||||
|
getDomainVerificationAttributes,
|
||||||
|
verifyDomain,
|
||||||
|
} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
export class DomainService {
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +77,43 @@ export class DomainService {
|
|||||||
|
|
||||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
const attributes = await getDomainVerificationAttributes(domain.domain);
|
||||||
|
|
||||||
|
// If domain failed verification, retry
|
||||||
|
if (attributes.status === 'Failed') {
|
||||||
|
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
const maxAttempts = 5;
|
||||||
|
let success = false;
|
||||||
|
let delay = 5000;
|
||||||
|
|
||||||
|
while (attempt < maxAttempts && !success) {
|
||||||
|
try {
|
||||||
|
await verifyDomain(domain.domain);
|
||||||
|
success = true;
|
||||||
|
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const error = e as {Code?: string; name?: string; message?: string};
|
||||||
|
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||||
|
signale.warn(
|
||||||
|
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||||
|
);
|
||||||
|
await new Promise(r => setTimeout(r, delay));
|
||||||
|
delay *= 2; // Exponential backoff
|
||||||
|
attempt++;
|
||||||
|
} else {
|
||||||
|
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
signale.error(
|
||||||
|
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update domain if verification status changed
|
// Update domain if verification status changed
|
||||||
if (attributes.status === 'Success' && !domain.verified) {
|
if (attributes.status === 'Success' && !domain.verified) {
|
||||||
const updatedDomain = await prisma.domain.update({
|
const updatedDomain = await prisma.domain.update({
|
||||||
@@ -84,15 +126,26 @@ export class DomainService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Disable feedback forwarding for verified domain
|
||||||
|
try {
|
||||||
|
await disableFeedbackForwarding(domain.domain);
|
||||||
|
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification about domain verified
|
// Send notification about domain verified
|
||||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||||
|
|
||||||
// Send email notification about domain verified
|
// Send email notification about domain verified
|
||||||
try {
|
try {
|
||||||
// Check deduplication cache
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const ttl = 604800; // 7 days
|
||||||
if (alreadySent !== '1') {
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -104,8 +157,6 @@ export class DomainService {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||||
// Set cache to prevent duplicate emails (7 days)
|
|
||||||
await redis.setex(cacheKey, 604800, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -131,13 +182,17 @@ export class DomainService {
|
|||||||
|
|
||||||
// Send email notification about domain verification failed
|
// Send email notification about domain verification failed
|
||||||
try {
|
try {
|
||||||
// Check deduplication cache (monthly)
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
if (alreadySent !== '1') {
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -149,10 +204,6 @@ export class DomainService {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||||
// Set cache to prevent duplicate emails (until end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -248,6 +299,28 @@ export class DomainService {
|
|||||||
|
|
||||||
await prisma.domain.delete({where: {id: domainId}});
|
await prisma.domain.delete({where: {id: domainId}});
|
||||||
|
|
||||||
|
// Check if this domain is still attached to another project
|
||||||
|
const domainExistsElsewhere = await prisma.domain.findFirst({
|
||||||
|
where: {
|
||||||
|
domain: domainName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If domain is not used by any other project, remove it from AWS SES
|
||||||
|
if (!domainExistsElsewhere) {
|
||||||
|
try {
|
||||||
|
await deleteIdentity(domainName);
|
||||||
|
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the domain removal if AWS cleanup fails
|
||||||
|
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signale.info(
|
||||||
|
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification about domain removal
|
// Send notification about domain removal
|
||||||
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface Attachment {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SendEmailParams {
|
interface SendEmailParams {
|
||||||
@@ -35,6 +37,7 @@ interface SendEmailParams {
|
|||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
workflowExecutionId?: string;
|
workflowExecutionId?: string;
|
||||||
workflowStepExecutionId?: string;
|
workflowStepExecutionId?: string;
|
||||||
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,7 +196,8 @@ export class EmailService {
|
|||||||
|
|
||||||
// Check subscription status for marketing emails
|
// Check subscription status for marketing emails
|
||||||
// Transactional emails should always be sent regardless of subscription status
|
// Transactional emails should always be sent regardless of subscription status
|
||||||
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
|
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
|
||||||
|
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: {id: params.contactId},
|
where: {id: params.contactId},
|
||||||
select: {subscribed: true},
|
select: {subscribed: true},
|
||||||
@@ -242,6 +246,12 @@ export class EmailService {
|
|||||||
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If custom recipient email is provided, store it in headers for later use
|
||||||
|
const emailHeaders = params.headers ? {...params.headers} : {};
|
||||||
|
if (params.recipientEmail) {
|
||||||
|
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await prisma.email.create({
|
const email = await prisma.email.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
@@ -251,7 +261,7 @@ export class EmailService {
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
|
||||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType,
|
sourceType,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
@@ -361,10 +371,25 @@ export class EmailService {
|
|||||||
? (email.headers as Record<string, string>)
|
? (email.headers as Record<string, string>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Check for custom recipient override in headers
|
||||||
|
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||||
|
|
||||||
|
// Remove internal headers before sending
|
||||||
|
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||||
|
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||||
|
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||||
|
}
|
||||||
|
|
||||||
// Parse attachments from JSON
|
// Parse attachments from JSON
|
||||||
const attachments =
|
const attachments =
|
||||||
email.attachments && Array.isArray(email.attachments)
|
email.attachments && Array.isArray(email.attachments)
|
||||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
? (email.attachments as Array<{
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
|
}>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
@@ -376,13 +401,13 @@ export class EmailService {
|
|||||||
name: fromName,
|
name: fromName,
|
||||||
email: fromEmail,
|
email: fromEmail,
|
||||||
},
|
},
|
||||||
to: [email.contact.email],
|
to: [recipientEmail],
|
||||||
content: {
|
content: {
|
||||||
subject: formattedEmail.subject,
|
subject: formattedEmail.subject,
|
||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
headers: customHeaders,
|
headers: publicHeaders,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
});
|
});
|
||||||
@@ -568,10 +593,11 @@ export class EmailService {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
|
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
||||||
prisma.email.count({where}),
|
prisma.email.count({where}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
||||||
|
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
||||||
@@ -582,6 +608,7 @@ export class EmailService {
|
|||||||
total,
|
total,
|
||||||
sent,
|
sent,
|
||||||
delivered,
|
delivered,
|
||||||
|
received,
|
||||||
opened,
|
opened,
|
||||||
clicked,
|
clicked,
|
||||||
bounced,
|
bounced,
|
||||||
@@ -623,6 +650,378 @@ 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 EmailPreviewModal.tsx.
|
||||||
|
*/
|
||||||
|
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) {
|
||||||
|
const classValue = match[1];
|
||||||
|
if (!classValue) continue;
|
||||||
|
const classes = classValue.split(/\s+/).filter((c: string) => c.length > 0);
|
||||||
|
const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle'];
|
||||||
|
const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => cls.startsWith(prefix)));
|
||||||
|
if (hasDisallowedClass) {
|
||||||
|
hasCustomClasses = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||||
|
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||||
|
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||||
|
const hasMediaQueries = /@media/i.test(html);
|
||||||
|
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||||
|
|
||||||
|
return (
|
||||||
|
hasInlineStyles ||
|
||||||
|
hasCustomClasses ||
|
||||||
|
hasCustomAttributes ||
|
||||||
|
hasComplexTables ||
|
||||||
|
hasCustomElements ||
|
||||||
|
hasMediaQueries ||
|
||||||
|
hasStyleTags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps visual editor content with a full HTML document and prose styles.
|
||||||
|
* Mirrors wrapEmailWithStyles() in EmailPreviewModal.tsx so sent emails
|
||||||
|
* match the preview modal exactly.
|
||||||
|
*/
|
||||||
|
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
/* Base reset */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tailwind Typography (prose) base styles */
|
||||||
|
.prose {
|
||||||
|
color: #374151;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.prose [class~="lead"] {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 1.2em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
}
|
||||||
|
.prose a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.prose strong {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prose ol, .prose ul {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
padding-left: 1.625em;
|
||||||
|
}
|
||||||
|
.prose li {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose ol > li {
|
||||||
|
padding-left: 0.375em;
|
||||||
|
}
|
||||||
|
.prose ul > li {
|
||||||
|
padding-left: 0.375em;
|
||||||
|
}
|
||||||
|
.prose > ul > li p {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.prose > ul > li > *:first-child {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ul > li > *:last-child {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ol > li > *:first-child {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ol > li > *:last-child {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: 3em;
|
||||||
|
margin-bottom: 3em;
|
||||||
|
}
|
||||||
|
.prose blockquote {
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
color: #111827;
|
||||||
|
border-left-width: 0.25rem;
|
||||||
|
border-left-color: #e5e7eb;
|
||||||
|
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 1.6em;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
.prose h1 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 2.25em;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.8888889em;
|
||||||
|
line-height: 1.1111111;
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
line-height: 1.3333333;
|
||||||
|
}
|
||||||
|
.prose h3 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.prose h4 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.prose img {
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.prose figure {
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.prose figure > * {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.prose code {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.prose code::before {
|
||||||
|
content: "\`";
|
||||||
|
}
|
||||||
|
.prose code::after {
|
||||||
|
content: "\`";
|
||||||
|
}
|
||||||
|
.prose pre {
|
||||||
|
color: #e5e7eb;
|
||||||
|
background-color: #1f2937;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
margin-top: 1.7142857em;
|
||||||
|
margin-bottom: 1.7142857em;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding-top: 0.8571429em;
|
||||||
|
padding-right: 1.1428571em;
|
||||||
|
padding-bottom: 0.8571429em;
|
||||||
|
padding-left: 1.1428571em;
|
||||||
|
}
|
||||||
|
.prose pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
.prose pre code::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.prose pre code::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.prose thead {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.prose thead th {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: bottom;
|
||||||
|
padding-right: 0.5714286em;
|
||||||
|
padding-bottom: 0.5714286em;
|
||||||
|
padding-left: 0.5714286em;
|
||||||
|
}
|
||||||
|
.prose tbody tr {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.prose tbody tr:last-child {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
.prose tbody td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding-top: 0.5714286em;
|
||||||
|
padding-right: 0.5714286em;
|
||||||
|
padding-bottom: 0.5714286em;
|
||||||
|
padding-left: 0.5714286em;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* prose-sm modifier */
|
||||||
|
.prose-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
}
|
||||||
|
.prose-sm p {
|
||||||
|
margin-top: 1.1428571em;
|
||||||
|
margin-bottom: 1.1428571em;
|
||||||
|
}
|
||||||
|
.prose-sm h1 {
|
||||||
|
font-size: 2.1428571em;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.prose-sm h2 {
|
||||||
|
font-size: 1.4285714em;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.prose-sm h3 {
|
||||||
|
font-size: 1.2857143em;
|
||||||
|
margin-top: 1.5555556em;
|
||||||
|
margin-bottom: 0.4444444em;
|
||||||
|
line-height: 1.5555556;
|
||||||
|
}
|
||||||
|
.prose-sm h4 {
|
||||||
|
margin-top: 1.4285714em;
|
||||||
|
margin-bottom: 0.5714286em;
|
||||||
|
line-height: 1.4285714;
|
||||||
|
}
|
||||||
|
.prose-sm img {
|
||||||
|
margin-top: 1.7142857em;
|
||||||
|
margin-bottom: 1.7142857em;
|
||||||
|
}
|
||||||
|
.prose-sm ol, .prose-sm ul {
|
||||||
|
margin-top: 1.1428571em;
|
||||||
|
margin-bottom: 1.1428571em;
|
||||||
|
padding-left: 1.5714286em;
|
||||||
|
}
|
||||||
|
.prose-sm li {
|
||||||
|
margin-top: 0.2857143em;
|
||||||
|
margin-bottom: 0.2857143em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* max-w-none utility */
|
||||||
|
.max-w-none {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom editor styles */
|
||||||
|
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th, .prose td {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-wrapper {
|
||||||
|
display: block;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-container {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-container img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="prose prose-sm max-w-none">
|
||||||
|
${htmlBody}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile HTML email with optional unsubscribe footer and badge
|
* Compile HTML email with optional unsubscribe footer and badge
|
||||||
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
||||||
@@ -638,7 +1037,9 @@ export class EmailService {
|
|||||||
project: Project;
|
project: Project;
|
||||||
includeUnsubscribe?: boolean;
|
includeUnsubscribe?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
let html = content;
|
// Wrap visual editor content with prose styles so the sent email matches the preview modal.
|
||||||
|
// Custom HTML (from the HTML editor) already carries its own styles and is used as-is.
|
||||||
|
let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content);
|
||||||
|
|
||||||
const unsubscribeHtml = includeUnsubscribe
|
const unsubscribeHtml = includeUnsubscribe
|
||||||
? (() => {
|
? (() => {
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {redis} from '../database/redis.js';
|
|||||||
const DISPOSABLE_DOMAINS_URL =
|
const DISPOSABLE_DOMAINS_URL =
|
||||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||||
|
const PERSONAL_DOMAINS_URL =
|
||||||
|
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
|
||||||
|
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
|
||||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||||
|
|
||||||
// Known email forwarding/alias services
|
// Known email forwarding/alias services
|
||||||
@@ -39,12 +42,15 @@ const FORWARDING_DOMAINS = new Set([
|
|||||||
|
|
||||||
export class EmailVerificationService {
|
export class EmailVerificationService {
|
||||||
private static disposableDomainsSet: Set<string> | null = null;
|
private static disposableDomainsSet: Set<string> | null = null;
|
||||||
|
private static personalDomainsSet: Set<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify an email address
|
* Verify an email address
|
||||||
* - Checks if domain exists (DNS A/AAAA records)
|
* - Checks for NS records (proves domain exists in DNS)
|
||||||
* - Checks for MX records
|
* - Checks for MX records (required for receiving email)
|
||||||
|
* - Checks for A/AAAA records (informational - indicates if domain has a website)
|
||||||
* - Detects disposable email addresses
|
* - Detects disposable email addresses
|
||||||
|
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
|
||||||
* - Detects forwarding/alias email addresses
|
* - Detects forwarding/alias email addresses
|
||||||
* - Suggests corrections for common typos
|
* - Suggests corrections for common typos
|
||||||
*/
|
*/
|
||||||
@@ -56,7 +62,9 @@ export class EmailVerificationService {
|
|||||||
isAlias: false,
|
isAlias: false,
|
||||||
isTypo: false,
|
isTypo: false,
|
||||||
isPlusAddressed: false,
|
isPlusAddressed: false,
|
||||||
|
isPersonalEmail: false,
|
||||||
domainExists: false,
|
domainExists: false,
|
||||||
|
hasWebsite: false,
|
||||||
hasMxRecords: false,
|
hasMxRecords: false,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
};
|
};
|
||||||
@@ -74,6 +82,9 @@ export class EmailVerificationService {
|
|||||||
// Check if email is from a disposable domain using GitHub list
|
// Check if email is from a disposable domain using GitHub list
|
||||||
result.isDisposable = await this.isDisposableDomain(domain);
|
result.isDisposable = await this.isDisposableDomain(domain);
|
||||||
|
|
||||||
|
// Check if email is from a personal/free email provider
|
||||||
|
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
|
||||||
|
|
||||||
// Check if email is from a known forwarding/alias service
|
// Check if email is from a known forwarding/alias service
|
||||||
result.isAlias = this.isForwardingDomain(domain);
|
result.isAlias = this.isForwardingDomain(domain);
|
||||||
|
|
||||||
@@ -88,35 +99,46 @@ export class EmailVerificationService {
|
|||||||
result.isTypo = true;
|
result.isTypo = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if domain exists (has any DNS records)
|
// Step 1: Check NS records - proves the domain exists in DNS
|
||||||
|
try {
|
||||||
|
const nsRecords = await dns.resolveNs(domain);
|
||||||
|
result.domainExists = nsRecords && nsRecords.length > 0;
|
||||||
|
} catch {
|
||||||
|
result.domainExists = false;
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain does not exist (no nameservers found)');
|
||||||
|
// If domain doesn't exist, no point checking MX/A records
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check MX records - required for receiving email
|
||||||
|
try {
|
||||||
|
const mxRecords = await dns.resolveMx(domain);
|
||||||
|
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||||
|
if (!result.hasMxRecords) {
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.hasMxRecords = false;
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check if domain has A/AAAA records - informational only
|
||||||
|
// This indicates if the domain has a website/web server
|
||||||
|
// Doesn't affect email validity since email only requires MX records
|
||||||
try {
|
try {
|
||||||
await dns.resolve(domain, 'A');
|
await dns.resolve(domain, 'A');
|
||||||
result.domainExists = true;
|
result.hasWebsite = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Try AAAA records if A records fail
|
// Try AAAA records if A records fail
|
||||||
try {
|
try {
|
||||||
await dns.resolve(domain, 'AAAA');
|
await dns.resolve(domain, 'AAAA');
|
||||||
result.domainExists = true;
|
result.hasWebsite = true;
|
||||||
} catch {
|
} catch {
|
||||||
result.domainExists = false;
|
// Domain doesn't have A/AAAA records (no website), but this is OK for email
|
||||||
result.valid = false;
|
result.hasWebsite = false;
|
||||||
result.reasons.push('Domain does not exist');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check MX records (only if domain exists)
|
|
||||||
if (result.domainExists) {
|
|
||||||
try {
|
|
||||||
const mxRecords = await dns.resolveMx(domain);
|
|
||||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
|
||||||
if (!result.hasMxRecords) {
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
result.hasMxRecords = false;
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,4 +209,57 @@ export class EmailVerificationService {
|
|||||||
private static isForwardingDomain(domain: string): boolean {
|
private static isForwardingDomain(domain: string): boolean {
|
||||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache the personal email domains list from GitHub
|
||||||
|
* Uses Redis for caching with 24-hour TTL
|
||||||
|
* Falls back to in-memory cache if Redis fails
|
||||||
|
*/
|
||||||
|
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||||
|
// Return in-memory cache if available
|
||||||
|
if (this.personalDomainsSet) {
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get from Redis cache first
|
||||||
|
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const domains = JSON.parse(cached) as string[];
|
||||||
|
this.personalDomainsSet = new Set(domains);
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from GitHub if not in cache
|
||||||
|
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const domains = text
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||||
|
|
||||||
|
// Cache in Redis
|
||||||
|
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
|
// Cache in memory
|
||||||
|
this.personalDomainsSet = new Set(domains);
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching personal email domains:', error);
|
||||||
|
// Return empty set as fallback - don't block email verification
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is a personal/free email provider
|
||||||
|
*/
|
||||||
|
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
|
||||||
|
const personalDomains = await this.getPersonalEmailDomains();
|
||||||
|
return personalDomains.has(domain.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,9 +211,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:security:warning:${projectId}`;
|
const cacheKey = `ntfy:security:warning:${projectId}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 3600; // 1 hour
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,14 +225,11 @@ export class NtfyService {
|
|||||||
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
||||||
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 1 hour
|
|
||||||
await redis.setex(cacheKey, 3600, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about email bounce - LOW priority (high volume)
|
* Notify about email bounce - LOW priority (high volume)
|
||||||
* Rate-limited to only send notification every 20 bounces with per-project breakdown
|
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||||
*/
|
*/
|
||||||
public static async notifyEmailBounce(
|
public static async notifyEmailBounce(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
@@ -241,63 +240,51 @@ export class NtfyService {
|
|||||||
// Import redis at runtime to avoid circular dependencies
|
// Import redis at runtime to avoid circular dependencies
|
||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
// Use Redis counters to track bounce count globally and per-project
|
// Use Redis counters to track bounce count globally
|
||||||
const globalCountKey = `ntfy:bounce:count`;
|
const globalCountKey = `ntfy:bounce:count`;
|
||||||
const projectCountKey = `ntfy:bounce:count:${projectId}`;
|
const bounceListKey = `ntfy:bounce:latest`;
|
||||||
const projectListKey = `ntfy:bounce:projects`;
|
|
||||||
|
|
||||||
// Increment global counter
|
// Increment global counter
|
||||||
const globalCount = await redis.incr(globalCountKey);
|
const globalCount = await redis.incr(globalCountKey);
|
||||||
|
|
||||||
// Increment project-specific counter
|
// Store this bounce event in a list (keep latest 20)
|
||||||
await redis.incr(projectCountKey);
|
const bounceEvent = JSON.stringify({
|
||||||
|
projectName,
|
||||||
// Add project to the set of projects with bounces (for tracking)
|
projectId,
|
||||||
await redis.sadd(projectListKey, projectId);
|
recipientEmail,
|
||||||
|
bounceType: bounceType || 'Unknown',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await redis.lpush(bounceListKey, bounceEvent);
|
||||||
|
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
|
||||||
|
|
||||||
// Set expiry on first increment (24 hour rolling window)
|
// Set expiry on first increment (24 hour rolling window)
|
||||||
if (globalCount === 1) {
|
if (globalCount === 1) {
|
||||||
await redis.expire(globalCountKey, 86400);
|
await redis.expire(globalCountKey, 86400);
|
||||||
await redis.expire(projectListKey, 86400);
|
await redis.expire(bounceListKey, 86400);
|
||||||
}
|
}
|
||||||
// Always refresh project counter expiry to match global window
|
|
||||||
await redis.expire(projectCountKey, 86400);
|
|
||||||
|
|
||||||
// Only send notification every 20 bounces
|
// Only send notification every 20 bounces
|
||||||
if (globalCount % 20 === 0) {
|
if (globalCount % 20 === 0) {
|
||||||
// Get all projects with bounces
|
// Get the latest 20 bounces
|
||||||
const projectIds = await redis.smembers(projectListKey);
|
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||||
|
|
||||||
// Get bounce counts for each project
|
// Parse and format the bounce events
|
||||||
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
|
const bounceDetails = latestBounces
|
||||||
for (const pid of projectIds) {
|
.map(bounce => {
|
||||||
const count = await redis.get(`ntfy:bounce:count:${pid}`);
|
try {
|
||||||
if (count) {
|
const parsed = JSON.parse(bounce);
|
||||||
// Fetch project name from database
|
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||||
const {prisma} = await import('../database/prisma.js');
|
} catch {
|
||||||
const project = await prisma.project.findUnique({
|
return null;
|
||||||
where: {id: pid},
|
}
|
||||||
select: {name: true},
|
})
|
||||||
});
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
projectCounts.push({
|
|
||||||
projectId: pid,
|
|
||||||
count: parseInt(count, 10),
|
|
||||||
name: project?.name || 'Unknown',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by count descending
|
|
||||||
projectCounts.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
// Build breakdown message
|
|
||||||
const breakdown = projectCounts.map(p => ` • ${p.name} (${p.projectId}): ${p.count}`).join('\n');
|
|
||||||
|
|
||||||
const bounceInfo = bounceType ? ` (${bounceType})` : '';
|
|
||||||
await this.send({
|
await this.send({
|
||||||
title: 'Email Bounces',
|
title: 'Email Bounces',
|
||||||
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
|
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||||
priority: NtfyPriority.LOW,
|
priority: NtfyPriority.LOW,
|
||||||
tags: [NtfyTag.WARNING],
|
tags: [NtfyTag.WARNING],
|
||||||
});
|
});
|
||||||
@@ -606,9 +593,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 86400; // 24 hours
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,9 +606,6 @@ export class NtfyService {
|
|||||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 24 hours
|
|
||||||
await redis.setex(cacheKey, 86400, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Billing and usage limit notifications =====
|
// ===== Billing and usage limit notifications =====
|
||||||
@@ -638,9 +624,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 86400; // 24 hours
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,9 +637,6 @@ export class NtfyService {
|
|||||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||||
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 24 hours
|
|
||||||
await redis.setex(cacheKey, 86400, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -765,6 +750,32 @@ export class NtfyService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify about bundled segment membership updates - LOW priority
|
||||||
|
* Used when multiple segments are updated in a single processing cycle
|
||||||
|
*/
|
||||||
|
public static async notifySegmentMembershipBundled(
|
||||||
|
projectName: string,
|
||||||
|
projectId: string,
|
||||||
|
segmentCount: number,
|
||||||
|
totalAdded: number,
|
||||||
|
totalRemoved: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const changes: string[] = [];
|
||||||
|
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
|
||||||
|
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
|
||||||
|
|
||||||
|
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
|
||||||
|
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
|
||||||
|
|
||||||
|
await this.send({
|
||||||
|
title: 'Segment Memberships Updated',
|
||||||
|
message,
|
||||||
|
priority: NtfyPriority.LOW,
|
||||||
|
tags: [NtfyTag.CHART],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about segment deleted - MIN priority
|
* Notify about segment deleted - MIN priority
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -282,14 +282,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get import job status and progress
|
* Get import job status and progress
|
||||||
|
* @param jobId - The job ID
|
||||||
|
* @param projectId - The project ID to verify authorization
|
||||||
|
* @returns Job status or null if not found or unauthorized
|
||||||
*/
|
*/
|
||||||
public static async getImportJobStatus(jobId: string) {
|
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await importQueue.getJob(jobId);
|
const job = await importQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Verify that the job belongs to the requesting project
|
||||||
|
if (job.data.projectId !== projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
@@ -324,14 +332,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bulk action job status and progress
|
* Get bulk action job status and progress
|
||||||
|
* @param jobId - The job ID
|
||||||
|
* @param projectId - The project ID to verify authorization
|
||||||
|
* @returns Job status or null if not found or unauthorized
|
||||||
*/
|
*/
|
||||||
public static async getBulkActionJobStatus(jobId: string) {
|
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await bulkContactQueue.getJob(jobId);
|
const job = await bulkContactQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Verify that the job belongs to the requesting project
|
||||||
|
if (job.data.projectId !== projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
TRACKING_TOGGLE_ENABLED
|
TRACKING_TOGGLE_ENABLED,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
|||||||
filename: string;
|
filename: string;
|
||||||
content: string; // Base64 encoded
|
content: string; // Base64 encoded
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
contentId?: string;
|
||||||
|
disposition?: 'attachment' | 'inline';
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
tracking?: boolean;
|
tracking?: boolean;
|
||||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique boundaries for multipart messages
|
// Generate unique boundaries for multipart messages
|
||||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||||
|
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||||
|
: null;
|
||||||
|
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||||
|
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
// Format To header with names if provided
|
// Format To header with names if provided
|
||||||
const toHeader = to
|
const toHeader = to
|
||||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
|||||||
// Extract just email addresses for Destinations (SES requirement)
|
// Extract just email addresses for Destinations (SES requirement)
|
||||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||||
|
|
||||||
|
// Determine root content type
|
||||||
|
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||||
|
if (mixedBoundary) {
|
||||||
|
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||||
|
} else if (relatedBoundary) {
|
||||||
|
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build raw MIME message
|
// Build raw MIME message
|
||||||
const rawMessage = `From: ${from.name} <${from.email}>
|
let rawMessage = `From: ${from.name} <${from.email}>
|
||||||
To: ${toHeader}
|
To: ${toHeader}
|
||||||
Reply-To: ${reply || from.email}
|
Reply-To: ${reply || from.email}
|
||||||
Subject: ${content.subject}
|
Subject: ${content.subject}
|
||||||
MIME-Version: 1.0
|
MIME-Version: 1.0
|
||||||
${
|
Content-Type: ${rootContentType}
|
||||||
mixedBoundary
|
|
||||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
|
||||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
|
||||||
}
|
|
||||||
${
|
${
|
||||||
headers
|
headers
|
||||||
? Object.entries(headers)
|
? Object.entries(headers)
|
||||||
@@ -138,29 +149,61 @@ ${
|
|||||||
}
|
}
|
||||||
${unsubscribeHeader}
|
${unsubscribeHeader}
|
||||||
|
|
||||||
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
|
`;
|
||||||
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
|
|
||||||
}--${boundary}
|
// building the body
|
||||||
|
if (mixedBoundary) {
|
||||||
|
rawMessage += `--${mixedBoundary}\n`;
|
||||||
|
if (relatedBoundary) {
|
||||||
|
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||||
|
rawMessage += `--${relatedBoundary}\n`;
|
||||||
|
}
|
||||||
|
} else if (relatedBoundary) {
|
||||||
|
rawMessage += `--${relatedBoundary}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are nested, we need to specify that this next part is the alternative container
|
||||||
|
if (mixedBoundary || relatedBoundary) {
|
||||||
|
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The alternative part content (always contains HTML)
|
||||||
|
rawMessage += `--${altBoundary}
|
||||||
Content-Type: text/html; charset=utf-8
|
Content-Type: text/html; charset=utf-8
|
||||||
Content-Transfer-Encoding: 7bit
|
Content-Transfer-Encoding: 7bit
|
||||||
|
|
||||||
${breakLongLines(content.html, 500)}
|
${breakLongLines(content.html, 500)}
|
||||||
--${boundary}--
|
--${altBoundary}--
|
||||||
${
|
`;
|
||||||
attachments && attachments.length > 0
|
|
||||||
? '\n' +
|
// Add inline attachments to the related container
|
||||||
attachments
|
if (relatedBoundary) {
|
||||||
.map(
|
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||||
attachment => `--${mixedBoundary}
|
for (const attachment of inlineAttachments) {
|
||||||
|
rawMessage += `\n--${relatedBoundary}
|
||||||
|
Content-Type: ${attachment.contentType}
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||||
|
Content-Disposition: inline; filename="${attachment.filename}"
|
||||||
|
|
||||||
|
${breakLongLines(attachment.content, 76, true)}`;
|
||||||
|
}
|
||||||
|
rawMessage += `\n--${relatedBoundary}--`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add regular attachments to the mixed container
|
||||||
|
if (mixedBoundary) {
|
||||||
|
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||||
|
for (const attachment of regularAttachments) {
|
||||||
|
rawMessage += `\n--${mixedBoundary}
|
||||||
Content-Type: ${attachment.contentType}
|
Content-Type: ${attachment.contentType}
|
||||||
Content-Transfer-Encoding: base64
|
Content-Transfer-Encoding: base64
|
||||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||||
|
|
||||||
${breakLongLines(attachment.content, 76, true)}`,
|
${breakLongLines(attachment.content, 76, true)}`;
|
||||||
)
|
}
|
||||||
.join('\n')
|
rawMessage += `\n--${mixedBoundary}--`;
|
||||||
: ''
|
}
|
||||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
|
||||||
|
|
||||||
// Determine which configuration set to use
|
// Determine which configuration set to use
|
||||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||||
@@ -258,6 +301,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a verified domain identity from AWS SES
|
||||||
|
*/
|
||||||
|
export const deleteIdentity = async (domain: string): Promise<void> => {
|
||||||
|
await ses.deleteIdentity({Identity: domain});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get AWS SES account sending quota and rate limit
|
* Get AWS SES account sending quota and rate limit
|
||||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ const SECURITY_THRESHOLDS = {
|
|||||||
BOUNCE_ALLTIME_CRITICAL: 8,
|
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||||
|
|
||||||
// Complaint rate thresholds (spam reports)
|
// Complaint rate thresholds (spam reports)
|
||||||
COMPLAINT_7DAY_WARNING: 0.05,
|
COMPLAINT_7DAY_WARNING: 0.075,
|
||||||
COMPLAINT_7DAY_CRITICAL: 0.1,
|
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||||
COMPLAINT_ALLTIME_WARNING: 0.02,
|
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||||
COMPLAINT_ALLTIME_CRITICAL: 0.08,
|
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||||
|
|
||||||
|
// Minimum absolute counts (prevents small sample size false positives)
|
||||||
|
// Both percentage AND absolute count must be exceeded to trigger
|
||||||
|
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||||
|
MIN_BOUNCES_FOR_WARNING: 5,
|
||||||
|
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||||
|
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface RateData {
|
interface RateData {
|
||||||
@@ -280,51 +287,77 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
// Critical: requires BOTH rate AND absolute count thresholds
|
||||||
|
if (
|
||||||
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||||
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
|
} else if (
|
||||||
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||||
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
// Critical: requires BOTH rate AND absolute count thresholds
|
||||||
|
if (
|
||||||
|
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||||
|
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
} else if (
|
||||||
|
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
|
||||||
|
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all-time rates (only if all-time volume is sufficient)
|
// Check all-time rates (only if all-time volume is sufficient)
|
||||||
if (hasMinimumVolumeAllTime) {
|
if (hasMinimumVolumeAllTime) {
|
||||||
// Check all-time bounce rate
|
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
||||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
if (
|
||||||
|
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||||
|
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
|
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
|
} else if (
|
||||||
|
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
|
||||||
|
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
|
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all-time complaint rate
|
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
|
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
|
} else if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
|
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
|
||||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
@@ -66,11 +66,33 @@ export class SegmentService {
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
): Promise<PaginatedResponse<Contact>> {
|
): Promise<PaginatedResponse<Contact>> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
|
||||||
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
// For static segments, query via SegmentMembership records
|
||||||
|
const [memberships, total] = await Promise.all([
|
||||||
|
prisma.segmentMembership.findMany({
|
||||||
|
where: {segmentId, exitedAt: null},
|
||||||
|
include: {contact: true},
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: {enteredAt: 'desc'},
|
||||||
|
}),
|
||||||
|
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: memberships.map(m => m.contact),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.ceil(total / pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
const [contacts, total] = await Promise.all([
|
const [contacts, total] = await Promise.all([
|
||||||
prisma.contact.findMany({
|
prisma.contact.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -98,23 +120,35 @@ export class SegmentService {
|
|||||||
data: {
|
data: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
condition: FilterCondition;
|
type?: SegmentType;
|
||||||
|
condition?: FilterCondition;
|
||||||
trackMembership?: boolean;
|
trackMembership?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): Promise<Segment> {
|
||||||
// Validate condition
|
const segmentType = data.type ?? 'DYNAMIC';
|
||||||
this.validateCondition(data.condition);
|
let memberCount = 0;
|
||||||
|
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
|
||||||
|
|
||||||
// Compute initial member count
|
if (segmentType === 'DYNAMIC') {
|
||||||
const where = this.buildWhereClause(projectId, data.condition);
|
if (!data.condition) {
|
||||||
const memberCount = await prisma.contact.count({where});
|
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
|
||||||
|
}
|
||||||
|
// Validate condition
|
||||||
|
this.validateCondition(data.condition);
|
||||||
|
|
||||||
|
// Compute initial member count
|
||||||
|
const where = this.buildWhereClause(projectId, data.condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
conditionJson = toPrismaJson(data.condition);
|
||||||
|
}
|
||||||
|
|
||||||
const segment = await prisma.segment.create({
|
const segment = await prisma.segment.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
condition: toPrismaJson(data.condition),
|
type: segmentType,
|
||||||
|
condition: conditionJson,
|
||||||
trackMembership: data.trackMembership ?? false,
|
trackMembership: data.trackMembership ?? false,
|
||||||
memberCount,
|
memberCount,
|
||||||
},
|
},
|
||||||
@@ -145,12 +179,7 @@ export class SegmentService {
|
|||||||
},
|
},
|
||||||
): Promise<Segment> {
|
): Promise<Segment> {
|
||||||
// First verify segment exists and belongs to project
|
// First verify segment exists and belongs to project
|
||||||
await this.get(projectId, segmentId);
|
const existing = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
// Validate condition if provided
|
|
||||||
if (data.condition) {
|
|
||||||
this.validateCondition(data.condition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: Prisma.SegmentUpdateInput = {};
|
const updateData: Prisma.SegmentUpdateInput = {};
|
||||||
|
|
||||||
@@ -160,7 +189,9 @@ export class SegmentService {
|
|||||||
if (data.description !== undefined) {
|
if (data.description !== undefined) {
|
||||||
updateData.description = data.description;
|
updateData.description = data.description;
|
||||||
}
|
}
|
||||||
if (data.condition !== undefined) {
|
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||||
|
// Validate condition if provided (only for DYNAMIC segments)
|
||||||
|
this.validateCondition(data.condition);
|
||||||
updateData.condition = toPrismaJson(data.condition);
|
updateData.condition = toPrismaJson(data.condition);
|
||||||
|
|
||||||
// Recompute member count when condition changes
|
// Recompute member count when condition changes
|
||||||
@@ -229,10 +260,16 @@ export class SegmentService {
|
|||||||
*/
|
*/
|
||||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
|
|
||||||
const memberCount = await prisma.contact.count({where});
|
let memberCount: number;
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
} else {
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segmentId},
|
where: {id: segmentId},
|
||||||
@@ -249,7 +286,7 @@ export class SegmentService {
|
|||||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
select: {id: true, condition: true},
|
select: {id: true, type: true, condition: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the database
|
// Process in batches to avoid overwhelming the database
|
||||||
@@ -260,9 +297,17 @@ export class SegmentService {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async segment => {
|
batch.map(async segment => {
|
||||||
try {
|
try {
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
let memberCount: number;
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
|
||||||
const memberCount = await prisma.contact.count({where});
|
if (segment.type === 'STATIC') {
|
||||||
|
memberCount = await prisma.segmentMembership.count({
|
||||||
|
where: {segmentId: segment.id, exitedAt: null},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
memberCount = await prisma.contact.count({where});
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.segment.update({
|
await prisma.segment.update({
|
||||||
where: {id: segment.id},
|
where: {id: segment.id},
|
||||||
@@ -276,6 +321,104 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add contacts to a static segment by email
|
||||||
|
*/
|
||||||
|
public static async addContacts(
|
||||||
|
projectId: string,
|
||||||
|
segmentId: string,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{added: number; notFound: string[]}> {
|
||||||
|
const segment = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
|
if (segment.type !== 'STATIC') {
|
||||||
|
throw new HttpException(400, 'Can only add contacts to STATIC segments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up contacts by email (case-insensitive)
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
email: {in: emails, mode: 'insensitive'},
|
||||||
|
},
|
||||||
|
select: {id: true, email: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||||
|
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||||
|
|
||||||
|
if (contacts.length > 0) {
|
||||||
|
// Check for existing memberships (to reactivate vs create new)
|
||||||
|
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||||
|
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
|
||||||
|
select: {contactId: true},
|
||||||
|
});
|
||||||
|
const existingIds = new Set(existingMemberships.map(m => m.contactId));
|
||||||
|
|
||||||
|
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
|
||||||
|
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
|
||||||
|
|
||||||
|
if (newContactIds.length > 0) {
|
||||||
|
await prisma.segmentMembership.createMany({
|
||||||
|
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reEntryIds.length > 0) {
|
||||||
|
await prisma.segmentMembership.updateMany({
|
||||||
|
where: {segmentId, contactId: {in: reEntryIds}},
|
||||||
|
data: {exitedAt: null, enteredAt: new Date()},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {added: contacts.length, notFound};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove contacts from a static segment by email
|
||||||
|
*/
|
||||||
|
public static async removeContacts(
|
||||||
|
projectId: string,
|
||||||
|
segmentId: string,
|
||||||
|
emails: string[],
|
||||||
|
): Promise<{removed: number}> {
|
||||||
|
const segment = await this.get(projectId, segmentId);
|
||||||
|
|
||||||
|
if (segment.type !== 'STATIC') {
|
||||||
|
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up contacts by email
|
||||||
|
const contacts = await prisma.contact.findMany({
|
||||||
|
where: {
|
||||||
|
projectId,
|
||||||
|
email: {in: emails, mode: 'insensitive'},
|
||||||
|
},
|
||||||
|
select: {id: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (contacts.length > 0) {
|
||||||
|
const contactIds = contacts.map(c => c.id);
|
||||||
|
|
||||||
|
await prisma.segmentMembership.updateMany({
|
||||||
|
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
|
||||||
|
data: {exitedAt: new Date()},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {removed: contacts.length};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute or recompute segment membership for all contacts
|
* Compute or recompute segment membership for all contacts
|
||||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
||||||
@@ -290,6 +433,13 @@ export class SegmentService {
|
|||||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segment.type === 'STATIC') {
|
||||||
|
// For static segments, just update the count from memberships — no contact scanning
|
||||||
|
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||||
|
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
|
||||||
|
return {added: 0, removed: 0, total};
|
||||||
|
}
|
||||||
|
|
||||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
@@ -584,8 +734,10 @@ export class SegmentService {
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -604,7 +756,9 @@ export class SegmentService {
|
|||||||
'greaterThanOrEqual',
|
'greaterThanOrEqual',
|
||||||
'lessThanOrEqual',
|
'lessThanOrEqual',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||||
@@ -612,7 +766,7 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate unit for time-based operators
|
// Validate unit for time-based operators
|
||||||
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
|
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,6 +883,20 @@ export class SegmentService {
|
|||||||
// Use ISO string for lexicographic comparison in JSON
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the "before" date (X time units ago from now)
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
|
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -822,6 +990,18 @@ export class SegmentService {
|
|||||||
|
|
||||||
return {[field]: {gte: since}};
|
return {[field]: {gte: since}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// "olderThan X days/hours/minutes" means more than X time units ago
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {[field]: {lt: before}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -921,6 +1101,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact triggered this event, but only more than X time ago (not recently)
|
||||||
|
// This means: has event AND all occurrences are before the cutoff
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have triggered the event at some point
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
some: {
|
||||||
|
name: eventName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
none: {
|
||||||
|
name: eventName,
|
||||||
|
createdAt: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never triggered this event
|
// Contact has never triggered this event
|
||||||
return {
|
return {
|
||||||
@@ -995,6 +1211,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact had this email activity, but only more than X time ago (not recently)
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have the email activity at some point
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
some: {
|
||||||
|
[field]: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
none: {
|
||||||
|
[field]: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never had this email activity
|
// Contact has never had this email activity
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {Keys} from './keys.js';
|
|||||||
* Extract base domain from URL for cookie sharing across subdomains
|
* Extract base domain from URL for cookie sharing across subdomains
|
||||||
* e.g., "http://api.example.com" -> ".example.com"
|
* e.g., "http://api.example.com" -> ".example.com"
|
||||||
* e.g., "http://api.localhost" -> ".localhost"
|
* e.g., "http://api.localhost" -> ".localhost"
|
||||||
|
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||||
*/
|
*/
|
||||||
function getCookieDomain(): string | undefined {
|
function getCookieDomain(): string | undefined {
|
||||||
if (NODE_ENV === 'development') {
|
if (NODE_ENV === 'development') {
|
||||||
@@ -28,10 +29,14 @@ function getCookieDomain(): string | undefined {
|
|||||||
// Extract base domain (last two parts for most domains, or .localhost)
|
// Extract base domain (last two parts for most domains, or .localhost)
|
||||||
const parts = hostname.split('.');
|
const parts = hostname.split('.');
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
// For *.localhost or *.local, use the full hostname with leading dot
|
// For *.localhost, use .localhost (reserved TLD)
|
||||||
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
if (hostname.endsWith('.localhost')) {
|
||||||
return '.localhost';
|
return '.localhost';
|
||||||
}
|
}
|
||||||
|
// For *.local (mDNS TLD), use the actual base domain
|
||||||
|
if (hostname.endsWith('.local')) {
|
||||||
|
return `.${parts.slice(-2).join('.')}`;
|
||||||
|
}
|
||||||
// For other domains, use the last two parts (e.g., .example.com)
|
// For other domains, use the last two parts (e.g., .example.com)
|
||||||
return `.${parts.slice(-2).join('.')}`;
|
return `.${parts.slice(-2).join('.')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,18 +519,25 @@ export class WorkflowExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEND_EMAIL step - Send an email to the contact
|
* SEND_EMAIL step - Send an email to the contact or a custom recipient
|
||||||
*/
|
*/
|
||||||
private static async executeSendEmail(
|
private static async executeSendEmail(
|
||||||
step: WorkflowStepWithTemplate,
|
step: WorkflowStepWithTemplate,
|
||||||
execution: WorkflowExecutionWithRelations,
|
execution: WorkflowExecutionWithRelations,
|
||||||
stepExecution: WorkflowStepExecution,
|
stepExecution: WorkflowStepExecution,
|
||||||
_config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
if (!step.template) {
|
if (!step.template) {
|
||||||
throw new Error('No template configured for SEND_EMAIL step');
|
throw new Error('No template configured for SEND_EMAIL step');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse step config to get recipient configuration
|
||||||
|
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||||
|
const recipientConfig =
|
||||||
|
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||||
|
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||||
|
: {type: 'CONTACT'};
|
||||||
|
|
||||||
// Get contact data for variable substitution
|
// Get contact data for variable substitution
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
const contactData =
|
const contactData =
|
||||||
@@ -558,10 +565,20 @@ export class WorkflowExecutionService {
|
|||||||
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
||||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||||
|
|
||||||
|
// Determine recipient email
|
||||||
|
let recipientEmail = contact.email;
|
||||||
|
let recipientContactId = contact.id;
|
||||||
|
|
||||||
|
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||||
|
recipientEmail = recipientConfig.customEmail;
|
||||||
|
// For custom recipients, we don't associate with a contact
|
||||||
|
recipientContactId = contact.id; // Keep original contact for tracking
|
||||||
|
}
|
||||||
|
|
||||||
// Send email via EmailService
|
// Send email via EmailService
|
||||||
const email = await EmailService.sendWorkflowEmail({
|
const email = await EmailService.sendWorkflowEmail({
|
||||||
projectId: execution.workflow.projectId,
|
projectId: execution.workflow.projectId,
|
||||||
contactId: contact.id,
|
contactId: recipientContactId,
|
||||||
workflowExecutionId: execution.id,
|
workflowExecutionId: execution.id,
|
||||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||||
templateId: step.template.id,
|
templateId: step.template.id,
|
||||||
@@ -570,11 +587,15 @@ export class WorkflowExecutionService {
|
|||||||
from: step.template.from,
|
from: step.template.from,
|
||||||
fromName: step.template.fromName || undefined,
|
fromName: step.template.fromName || undefined,
|
||||||
replyTo: step.template.replyTo || undefined,
|
replyTo: step.template.replyTo || undefined,
|
||||||
|
// Pass custom recipient email if specified
|
||||||
|
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
sentAt: email.createdAt,
|
sentAt: email.createdAt,
|
||||||
|
recipientType: recipientConfig.type,
|
||||||
|
recipientEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,25 +194,57 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
// Use transaction to update workflow and TRIGGER step atomically
|
||||||
|
const updated = await prisma.$transaction(async tx => {
|
||||||
|
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||||
if (data.triggerConfig !== undefined) {
|
if (data.triggerConfig !== undefined) {
|
||||||
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
||||||
}
|
}
|
||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||||
|
|
||||||
const updated = await prisma.workflow.update({
|
const updatedWorkflow = await tx.workflow.update({
|
||||||
where: {id: workflowId},
|
where: {id: workflowId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {name: true},
|
select: {name: true},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||||
|
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||||
|
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||||
|
const eventName = newTriggerConfig?.eventName;
|
||||||
|
|
||||||
|
if (eventName) {
|
||||||
|
// Find the TRIGGER step
|
||||||
|
const triggerStep = await tx.workflowStep.findFirst({
|
||||||
|
where: {
|
||||||
|
workflowId: workflowId,
|
||||||
|
type: 'TRIGGER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (triggerStep) {
|
||||||
|
// Update TRIGGER step config and name to match
|
||||||
|
await tx.workflowStep.update({
|
||||||
|
where: {id: triggerStep.id},
|
||||||
|
data: {
|
||||||
|
name: `Trigger: ${eventName}`,
|
||||||
|
config: {eventName},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedWorkflow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||||
@@ -220,6 +252,11 @@ export class WorkflowService {
|
|||||||
await EventService.invalidateWorkflowCache(projectId);
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||||
|
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||||
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification if enabled status changed
|
// Send notification if enabled status changed
|
||||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||||
if (data.enabled) {
|
if (data.enabled) {
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
import {sendRawEmail} from '../SESService';
|
import {sendRawEmail} from '../SESService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock SES service
|
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
|
||||||
|
vi.mock('@aws-sdk/client-ses', () => {
|
||||||
|
const SESMock = vi.fn();
|
||||||
|
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
|
||||||
|
return {SES: SESMock};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock constants to provide AWS credentials for SESService, preserving other exports
|
||||||
|
vi.mock('../../app/constants.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../../app/constants.js')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
|
||||||
|
AWS_SES_REGION: 'us-east-1',
|
||||||
|
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
|
||||||
|
SES_CONFIGURATION_SET: 'test-config-set',
|
||||||
|
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
|
||||||
|
TRACKING_TOGGLE_ENABLED: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock SES service (default behavior for most tests)
|
||||||
vi.mock('../SESService', () => ({
|
vi.mock('../SESService', () => ({
|
||||||
sendRawEmail: vi.fn(),
|
sendRawEmail: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -801,5 +822,204 @@ describe('EmailService', () => {
|
|||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should accept inline attachment with contentId', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
from: '[email protected]',
|
||||||
|
subject: 'Inline Image',
|
||||||
|
body: '<img src="cid:logo" />',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'logo.png',
|
||||||
|
content: Buffer.from('image').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'logo',
|
||||||
|
disposition: 'inline',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
const attachment = result.data.attachments![0];
|
||||||
|
expect(attachment.contentId).toBe('logo');
|
||||||
|
expect(attachment.disposition).toBe('inline');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject contentId exceeding 255 chars', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content: Buffer.from('content').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'a'.repeat(256),
|
||||||
|
disposition: 'inline',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid disposition', () => {
|
||||||
|
const result = ActionSchemas.send.safeParse({
|
||||||
|
to: '[email protected]',
|
||||||
|
subject: 'Test',
|
||||||
|
body: 'Test',
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content: Buffer.from('content').toString('base64'),
|
||||||
|
contentType: 'image/png',
|
||||||
|
disposition: 'invalid-disposition',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// SES MIME BOUNDARY STRUCTURE
|
||||||
|
// ========================================
|
||||||
|
// These tests verify the raw MIME assembly logic inside sendRawEmail.
|
||||||
|
// They need the REAL sendRawEmail (not the mock above), so we mock
|
||||||
|
// at the AWS SDK level instead.
|
||||||
|
|
||||||
|
describe('SES MIME Boundary Structure', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
|
||||||
|
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
from: {name: 'Sender', email: '[email protected]'},
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'test.txt',
|
||||||
|
content: 'SGVsbG8=',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
disposition: 'attachment' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await realSendRawEmail(params);
|
||||||
|
|
||||||
|
expect(ses.sendRawEmail).toHaveBeenCalled();
|
||||||
|
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||||
|
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||||
|
|
||||||
|
// Verify boundary hierarchy: Mixed -> Alternative
|
||||||
|
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||||
|
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||||
|
|
||||||
|
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||||
|
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
|
||||||
|
|
||||||
|
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
|
||||||
|
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
|
||||||
|
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
from: {name: 'Sender', email: '[email protected]'},
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content:
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'image1',
|
||||||
|
disposition: 'inline' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await realSendRawEmail(params);
|
||||||
|
|
||||||
|
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||||
|
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||||
|
|
||||||
|
// Verify boundary hierarchy: Related -> Alternative
|
||||||
|
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
|
||||||
|
|
||||||
|
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||||
|
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
|
||||||
|
|
||||||
|
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||||
|
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
|
||||||
|
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly nest mixed > related > alternative boundaries', async () => {
|
||||||
|
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
from: {name: 'Sender', email: '[email protected]'},
|
||||||
|
to: ['[email protected]'],
|
||||||
|
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
filename: 'test.txt',
|
||||||
|
content: 'SGVsbG8=',
|
||||||
|
contentType: 'text/plain',
|
||||||
|
disposition: 'attachment' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: 'image.png',
|
||||||
|
content:
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||||
|
contentType: 'image/png',
|
||||||
|
contentId: 'image1',
|
||||||
|
disposition: 'inline' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await realSendRawEmail(params);
|
||||||
|
|
||||||
|
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||||
|
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||||
|
|
||||||
|
// Root should be mixed
|
||||||
|
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||||
|
|
||||||
|
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
|
||||||
|
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
|
||||||
|
|
||||||
|
// Within mixed, we should find related
|
||||||
|
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
|
||||||
|
|
||||||
|
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
|
||||||
|
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
|
||||||
|
|
||||||
|
// Within related, we should find alternative
|
||||||
|
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||||
|
|
||||||
|
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||||
|
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
|
||||||
|
|
||||||
|
// Verify all closing boundaries exist
|
||||||
|
expect(rawMessage).toContain(`--${altBoundary}--`);
|
||||||
|
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||||
|
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ export default function Footer() {
|
|||||||
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
{/* Logo and description */}
|
{/* Logo and description */}
|
||||||
<div className="space-y-6 lg:col-span-4">
|
<div className="space-y-6 lg:col-span-3">
|
||||||
<div className={'relative h-8 w-8'}>
|
<div className={'relative h-8 w-8'}>
|
||||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-neutral-600">
|
<p className="text-sm leading-relaxed text-neutral-600">
|
||||||
Open-source email automation platform that scales
|
Open-source email platform for transactional, marketing, and automation. EU-hosted, GDPR compliant.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -57,7 +57,7 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div className="grid grid-cols-2 gap-8 lg:col-span-8 lg:grid-cols-3">
|
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
|
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
|
||||||
<ul role="list" className="mt-6 space-y-4">
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
@@ -67,20 +67,95 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={WIKI_URI} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'/made-by-humans'}
|
Documentation
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Made by humans
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'/guides'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={WIKI_URI}
|
Guides
|
||||||
target={'_blank'}
|
</Link>
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
</li>
|
||||||
>
|
<li>
|
||||||
Documentation
|
<Link href={'/tools'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Tools
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Made by humans
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">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">
|
||||||
|
Email editor
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/workflows'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Workflows
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/segments'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Segments
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/smtp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
SMTP
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/inbound-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Inbound email
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">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">
|
||||||
|
All comparisons
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/mailchimp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Mailchimp
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/sendgrid'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs SendGrid
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/resend'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Resend
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/brevo'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Brevo
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/mailgun'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Mailgun
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/convertkit'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs ConvertKit
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -95,28 +170,18 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'https://github.com/useplunk'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'https://github.com/useplunk'}
|
|
||||||
target={'_blank'}
|
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'https://status.useplunk.com'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'https://status.useplunk.com'}
|
|
||||||
target={'_blank'}
|
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Status
|
Status
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<h3 className="mt-8 text-sm font-semibold text-neutral-900">Legal</h3>
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Legal</h3>
|
|
||||||
<ul role="list" className="mt-6 space-y-4">
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
<li>
|
<li>
|
||||||
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
@@ -128,6 +193,52 @@ export default function Footer() {
|
|||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
DPA
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900">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">
|
||||||
|
Email deliverability
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/transactional-vs-marketing-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Transactional vs marketing
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-dkim'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is DKIM?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-spf'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is SPF?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-dmarc'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is DMARC?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/email-open-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Email open rates
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/email-bounce-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Email bounce rates
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,193 +4,305 @@ import {AnimatePresence, motion} from 'framer-motion';
|
|||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import logo from '../../../public/assets/logo.svg';
|
import logo from '../../../public/assets/logo.svg';
|
||||||
|
import {ChevronDown, GitBranch, Inbox, Mail, Server, Users} from 'lucide-react';
|
||||||
|
|
||||||
|
const featuresMenu = [
|
||||||
|
{
|
||||||
|
title: 'Email Editor',
|
||||||
|
description: 'Create beautiful emails with visual or code editing',
|
||||||
|
href: '/features/email-editor',
|
||||||
|
icon: <Mail className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Workflows',
|
||||||
|
description: 'Automate email sequences with triggers and conditions',
|
||||||
|
href: '/features/workflows',
|
||||||
|
icon: <GitBranch className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Inbound Email',
|
||||||
|
description: 'Receive and process incoming emails',
|
||||||
|
href: '/features/inbound-email',
|
||||||
|
icon: <Inbox className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Segments',
|
||||||
|
description: 'Organize contacts with dynamic filtering',
|
||||||
|
href: '/features/segments',
|
||||||
|
icon: <Users className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SMTP',
|
||||||
|
description: 'Send emails via SMTP or API',
|
||||||
|
href: '/features/smtp',
|
||||||
|
icon: <Server className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [featuresOpen, setFeaturesOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={'relative top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
|
<header className={'sticky top-0 z-40 w-full border-b border-neutral-100 bg-white/95 backdrop-blur-sm'}>
|
||||||
<div className={'z-40 py-6'}>
|
<div className={'relative mx-auto max-w-7xl px-8 xl:px-0'}>
|
||||||
<div className="flex items-center justify-between">
|
<div className={'py-5'}>
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div className="flex items-center gap-12">
|
||||||
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
<div className="flex flex-shrink-0 items-center">
|
||||||
<div className={'relative h-8 w-8'}>
|
<Link href={'/'} className={'flex items-center gap-x-3'}>
|
||||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
<div className={'relative h-8 w-8'}>
|
||||||
|
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||||
|
</div>
|
||||||
|
<span className={'sr-only'}>Plunk</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="hidden items-center gap-8 md:flex">
|
||||||
|
<div className={'relative'}>
|
||||||
|
<button
|
||||||
|
onMouseEnter={() => setFeaturesOpen(true)}
|
||||||
|
onMouseLeave={() => setFeaturesOpen(false)}
|
||||||
|
className={
|
||||||
|
'flex items-center gap-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${featuresOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{featuresOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: -10}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -10}}
|
||||||
|
transition={{duration: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||||
|
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
<span className={'sr-only'}>Plunk</span>
|
|
||||||
</Link>
|
<Link
|
||||||
|
href={'/made-by-humans'}
|
||||||
|
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||||
|
>
|
||||||
|
By humans
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={'/pricing'}
|
||||||
|
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={'/guides'}
|
||||||
|
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||||
|
>
|
||||||
|
Guides
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={WIKI_URI}
|
||||||
|
target={'_blank'}
|
||||||
|
rel={'noreferrer'}
|
||||||
|
className={
|
||||||
|
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Docs
|
||||||
|
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
||||||
|
/>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden items-center gap-8 md:flex">
|
|
||||||
<Link
|
<div className="hidden items-center gap-6 md:flex">
|
||||||
href={'/made-by-humans'}
|
<a
|
||||||
|
href={`${DASHBOARD_URI}/auth/login`}
|
||||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||||
>
|
>
|
||||||
By humans
|
Sign in
|
||||||
</Link>
|
</a>
|
||||||
|
<motion.a
|
||||||
<Link
|
whileHover={{scale: 1.02}}
|
||||||
href={'/pricing'}
|
whileTap={{scale: 0.98}}
|
||||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={'/guides'}
|
|
||||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
|
||||||
>
|
|
||||||
Guides
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={WIKI_URI}
|
|
||||||
target={'_blank'}
|
|
||||||
rel={'noreferrer'}
|
|
||||||
className={
|
className={
|
||||||
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Docs
|
Get started
|
||||||
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
</motion.a>
|
||||||
<path
|
</div>
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
<div className="-mr-2 flex items-center md:hidden">
|
||||||
strokeWidth="2"
|
<button
|
||||||
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
/>
|
type="button"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
|
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
|
aria-controls="mobile-menu"
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Open main menu</span>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</Link>
|
|
||||||
|
<svg
|
||||||
|
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden items-center gap-6 md:flex">
|
|
||||||
<a
|
|
||||||
href={`${DASHBOARD_URI}/auth/login`}
|
|
||||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</a>
|
|
||||||
<motion.a
|
|
||||||
whileHover={{scale: 1.02}}
|
|
||||||
whileTap={{scale: 0.98}}
|
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
|
||||||
className={
|
|
||||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Get started
|
|
||||||
</motion.a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="-mr-2 flex items-center md:hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
|
||||||
aria-controls="mobile-menu"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Open main menu</span>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
className={`${mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<svg
|
|
||||||
className={`${!mobileOpen ? 'hidden' : 'block'} h-6 w-6`}
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<motion.div
|
|
||||||
initial={{height: 0, opacity: 0}}
|
|
||||||
animate={{height: 'auto', opacity: 1}}
|
|
||||||
exit={{height: 0, opacity: 0}}
|
|
||||||
transition={{duration: 0.2}}
|
|
||||||
className="absolute left-0 top-full z-50 mt-2 w-full rounded-lg border border-neutral-200 bg-white shadow-lg backdrop-blur-sm sm:hidden"
|
|
||||||
>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0}}
|
initial={{height: 0, opacity: 0}}
|
||||||
animate={{opacity: 1}}
|
animate={{height: 'auto', opacity: 1}}
|
||||||
exit={{opacity: 0}}
|
exit={{height: 0, opacity: 0}}
|
||||||
transition={{duration: 0.2}}
|
transition={{duration: 0.2}}
|
||||||
className="space-y-1 p-4"
|
className="absolute left-0 top-full z-50 w-full overflow-hidden border-t border-neutral-100 bg-white shadow-lg md:hidden"
|
||||||
>
|
>
|
||||||
<Link
|
<motion.div
|
||||||
href={'/made-by-humans'}
|
initial={{opacity: 0}}
|
||||||
onClick={() => setMobileOpen(false)}
|
animate={{opacity: 1}}
|
||||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
exit={{opacity: 0}}
|
||||||
|
transition={{duration: 0.2}}
|
||||||
|
className="space-y-1 p-4"
|
||||||
>
|
>
|
||||||
By humans
|
<div className="mb-2">
|
||||||
</Link>
|
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||||
|
Features
|
||||||
|
</div>
|
||||||
|
{featuresMenu.map(feature => (
|
||||||
|
<Link
|
||||||
|
key={feature.href}
|
||||||
|
href={feature.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="flex items-start gap-3 rounded-lg px-4 py-3 transition hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||||
|
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={'/pricing'}
|
href={'/made-by-humans'}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Pricing
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href={'/guides'}
|
|
||||||
onClick={() => setMobileOpen(false)}
|
|
||||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Guides
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href={WIKI_URI}
|
|
||||||
target={'_blank'}
|
|
||||||
rel={'noreferrer'}
|
|
||||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div className="border-t border-neutral-200 pt-4">
|
|
||||||
<a
|
|
||||||
href={`${DASHBOARD_URI}/auth/login`}
|
|
||||||
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
>
|
>
|
||||||
Sign in
|
By humans
|
||||||
</a>
|
</Link>
|
||||||
<a
|
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
<Link
|
||||||
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"
|
href={'/pricing'}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
>
|
>
|
||||||
Get started
|
Pricing
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={'/guides'}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Guides
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={WIKI_URI}
|
||||||
|
target={'_blank'}
|
||||||
|
rel={'noreferrer'}
|
||||||
|
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Docs
|
||||||
</a>
|
</a>
|
||||||
</div>
|
|
||||||
|
<div className="border-t border-neutral-200 pt-4">
|
||||||
|
<a
|
||||||
|
href={`${DASHBOARD_URI}/auth/login`}
|
||||||
|
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
)}
|
||||||
)}
|
</AnimatePresence>
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Forward,
|
||||||
Info,
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
User,
|
||||||
XCircle,
|
XCircle,
|
||||||
Forward,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
|
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||||
|
|
||||||
interface EmailVerificationResultProps {
|
interface EmailVerificationResultProps {
|
||||||
result: VerificationResult;
|
result: VerificationResult;
|
||||||
@@ -52,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
<Server className="h-5 w-5 text-neutral-600" />
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900">Domain Exists</p>
|
<p className="font-medium text-neutral-900">Domain Exists</p>
|
||||||
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
<p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{result.domainExists ? (
|
{result.domainExists ? (
|
||||||
@@ -78,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Website/A Records */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Has Website</p>
|
||||||
|
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.hasWebsite ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Info className="h-5 w-5 text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Disposable Email */}
|
{/* Disposable Email */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -94,6 +111,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Email */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Personal Email</p>
|
||||||
|
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.isPersonalEmail ? (
|
||||||
|
<Info className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-neutral-500">No</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Alias/Forwarding Email */}
|
{/* Alias/Forwarding Email */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
export interface EmailVerificationResult {
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isAlias: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||||
const response = await fetch('/api/verify-email', {
|
const response = await fetch('/api/verify-email', {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
|
import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Educational content for the email verification tool
|
* Educational content for the email verification tool
|
||||||
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
|
|||||||
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Personal Email Detection',
|
||||||
|
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
|
||||||
|
icon: User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Plus Addressing',
|
title: 'Plus Addressing',
|
||||||
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {toast, Toaster} from 'sonner';
|
|||||||
import {SWRConfig} from 'swr';
|
import {SWRConfig} from 'swr';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
import {DefaultSeo} from 'next-seo';
|
import {DefaultSeo} from 'next-seo';
|
||||||
|
import Script from 'next/script';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main app component
|
* Main app component
|
||||||
@@ -15,10 +16,6 @@ import {DefaultSeo} from 'next-seo';
|
|||||||
*/
|
*/
|
||||||
function App({Component, pageProps}: AppProps) {
|
function App({Component, pageProps}: AppProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const message = searchParams.get('message');
|
const message = searchParams.get('message');
|
||||||
|
|
||||||
@@ -68,6 +65,13 @@ export default function WithProviders(props: AppProps) {
|
|||||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src="https://analytics.driaug.com/script.js"
|
||||||
|
data-website-id="6ed9fa6c-3a75-4926-ad4d-f607557f79f1"
|
||||||
|
data-domains="www.useplunk.com"
|
||||||
|
/>
|
||||||
|
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||||
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
import {UtilitySchemas} from '@plunk/shared';
|
import {UtilitySchemas} from '@plunk/shared';
|
||||||
import {API_URI} from '../../lib/constants';
|
import {API_URI} from '../../lib/constants';
|
||||||
|
|
||||||
interface VerifyEmailResponse {
|
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<VerifyEmailResponse | ErrorResponse>) {
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
|
||||||
|
) {
|
||||||
// Only allow POST requests
|
// Only allow POST requests
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
return res.status(405).json({error: 'Method not allowed'});
|
return res.status(405).json({error: 'Method not allowed'});
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import {NextSeo} from 'next-seo';
|
||||||
|
import React from 'react';
|
||||||
|
import {Navbar} from '../components';
|
||||||
|
|
||||||
|
export default function DataProcessingAgreement() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NextSeo
|
||||||
|
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
|
||||||
|
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
|
||||||
|
openGraph={{
|
||||||
|
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
description:
|
||||||
|
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
|
||||||
|
}}
|
||||||
|
additionalMetaTags={[
|
||||||
|
{
|
||||||
|
property: 'title',
|
||||||
|
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl py-16">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-neutral-700">
|
||||||
|
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
|
||||||
|
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
|
||||||
|
accordance with GDPR requirements.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<p className="text-sm leading-relaxed text-blue-900">
|
||||||
|
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
|
||||||
|
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table of Contents */}
|
||||||
|
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
|
||||||
|
<ol className="space-y-2 text-sm text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<a href="#definitions" className="text-blue-600 hover:underline">
|
||||||
|
1. Definitions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#scope" className="text-blue-600 hover:underline">
|
||||||
|
2. Scope & Applicability
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#processing-details" className="text-blue-600 hover:underline">
|
||||||
|
3. Processing Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#obligations" className="text-blue-600 hover:underline">
|
||||||
|
4. Data Processor Obligations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#security" className="text-blue-600 hover:underline">
|
||||||
|
5. Security Measures
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#sub-processors" className="text-blue-600 hover:underline">
|
||||||
|
6. Sub-Processors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
|
||||||
|
7. Data Subject Rights
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-breach" className="text-blue-600 hover:underline">
|
||||||
|
8. Data Breach Notification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#international-transfers" className="text-blue-600 hover:underline">
|
||||||
|
9. International Data Transfers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#audits" className="text-blue-600 hover:underline">
|
||||||
|
10. Audits & Compliance
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#deletion" className="text-blue-600 hover:underline">
|
||||||
|
11. Data Deletion & Return
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#liability" className="text-blue-600 hover:underline">
|
||||||
|
12. Liability & Indemnification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#term" className="text-blue-600 hover:underline">
|
||||||
|
13. Term & Termination
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content Sections */}
|
||||||
|
<div className="space-y-12">
|
||||||
|
{/* Section 1 */}
|
||||||
|
<section id="definitions" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
|
||||||
|
relating to an identified or identifiable natural person processed via Plunk
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
|
||||||
|
determines the purposes and means of processing Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
|
||||||
|
processes Personal Data on behalf of the Data Controller
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
|
||||||
|
engaged by Plunk to process Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
|
||||||
|
whose Personal Data is processed (your contacts/subscribers)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
|
||||||
|
(General Data Protection Regulation)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2 */}
|
||||||
|
<section id="scope" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
|
||||||
|
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
|
||||||
|
self-hosting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
|
||||||
|
processing:
|
||||||
|
</p>
|
||||||
|
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA takes precedence</li>
|
||||||
|
<li>Then the Terms of Service</li>
|
||||||
|
<li>Then the Privacy Policy</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
|
||||||
|
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3 */}
|
||||||
|
<section id="processing-details" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 28(3), the following details describe the processing activities:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing of Personal Data necessary to provide email automation, transactional email, marketing
|
||||||
|
campaigns, and workflow automation services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
|
||||||
|
(which are automatically deleted), and until you request account deletion.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Storing and managing contact databases</li>
|
||||||
|
<li>Sending transactional, marketing, and workflow-triggered emails</li>
|
||||||
|
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
|
||||||
|
<li>Managing email templates and automation workflows</li>
|
||||||
|
<li>Processing email events and webhooks</li>
|
||||||
|
<li>Providing analytics and reporting</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email addresses (required)</li>
|
||||||
|
<li>Names and custom contact fields (optional, Customer-defined)</li>
|
||||||
|
<li>Email content (subject lines, message bodies, attachments)</li>
|
||||||
|
<li>Subscription status and preferences</li>
|
||||||
|
<li>Email activity data (opens, clicks, bounces, complaints)</li>
|
||||||
|
<li>Timestamps and metadata</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer's email subscribers and contacts</li>
|
||||||
|
<li>Recipients of transactional emails</li>
|
||||||
|
<li>Marketing campaign recipients</li>
|
||||||
|
<li>Workflow automation recipients</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 4 */}
|
||||||
|
<section id="obligations" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk commits to the following obligations as Data Processor:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
|
||||||
|
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
|
||||||
|
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
|
||||||
|
<li>
|
||||||
|
Not disclose Personal Data to third parties except as required by law or with Customer consent
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
|
||||||
|
<li>
|
||||||
|
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
|
||||||
|
assessment obligations
|
||||||
|
</li>
|
||||||
|
<li>Provide information necessary to demonstrate compliance with Article 28</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 5 */}
|
||||||
|
<section id="security" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
|
||||||
|
ensure a level of security appropriate to the risk:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Industry-standard password hashing (never stored in plaintext)</li>
|
||||||
|
<li>HTTPS-only API access (TLS 1.2+)</li>
|
||||||
|
<li>Database connections encrypted via TLS/SSL</li>
|
||||||
|
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
|
||||||
|
<li>Authentication tokens with limited expiration periods</li>
|
||||||
|
<li>Rate limiting to prevent brute force attacks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Access controls limiting personnel access to Personal Data</li>
|
||||||
|
<li>Automated bounce and complaint rate monitoring</li>
|
||||||
|
<li>Regular security updates and patching</li>
|
||||||
|
<li>Data segregation by project (multi-tenancy with isolation)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
|
||||||
|
<li>Database and file storage remain within EU/EEA</li>
|
||||||
|
<li>Email delivery may transit non-EU regions (see Section 9)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 6 */}
|
||||||
|
<section id="sub-processors" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full border border-neutral-300">
|
||||||
|
<thead className="bg-neutral-50">
|
||||||
|
<tr>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Sub-Processor
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Service
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Purpose
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white">
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Amazon Web Services (AWS SES)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Global (data in transit only)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Sending emails to recipients
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Payment Processing
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
USA (PCI-DSS compliant)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Billing for paid accounts
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Hetzner Online GmbH
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Infrastructure Hosting
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
EU/EEA (Germany)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Database and application hosting
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
|
||||||
|
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
|
||||||
|
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
|
||||||
|
<li>Notice will be sent to the email address associated with your account</li>
|
||||||
|
<li>
|
||||||
|
If you object on reasonable grounds related to data protection, you may terminate your account
|
||||||
|
within 30 days
|
||||||
|
</li>
|
||||||
|
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 7 */}
|
||||||
|
<section id="data-subject-rights" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer can fulfill most Data Subject requests independently via API:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
|
||||||
|
via GET /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
|
||||||
|
data via PATCH /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
|
||||||
|
DELETE /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
|
||||||
|
subscription status to "unsubscribed"
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
|
||||||
|
data in JSON format
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
If Customer cannot fulfill a request via API, contact legal@useplunk.com:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Provide Data Subject's email address and nature of request</li>
|
||||||
|
<li>Plunk will respond within 10 business days with requested information or assistance</li>
|
||||||
|
<li>
|
||||||
|
Customer remains responsible for verifying Data Subject identity before disclosing information
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
|
||||||
|
<li>Deleting a contact will cascade delete associated email activity</li>
|
||||||
|
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 8 */}
|
||||||
|
<section id="data-breach" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In the event of a Personal Data breach affecting Customer data, Plunk will:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
|
||||||
|
Art. 33)
|
||||||
|
</li>
|
||||||
|
<li>Send notification to the primary email address associated with Customer's account</li>
|
||||||
|
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Notifications will include (to the extent known):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Nature of the breach (what happened)</li>
|
||||||
|
<li>Categories and approximate number of Data Subjects affected</li>
|
||||||
|
<li>Categories and approximate number of Personal Data records affected</li>
|
||||||
|
<li>Likely consequences of the breach</li>
|
||||||
|
<li>Measures taken or proposed to address the breach and mitigate harm</li>
|
||||||
|
<li>Contact point for more information (legal@useplunk.com)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
|
||||||
|
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
|
||||||
|
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 9 */}
|
||||||
|
<section id="international-transfers" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
|
||||||
|
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When emails are sent to recipients, Personal Data may transit through non-EU regions:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>AWS SES processes emails globally for delivery purposes</li>
|
||||||
|
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
|
||||||
|
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
|
||||||
|
<li>Stripe is PCI-DSS Level 1 certified</li>
|
||||||
|
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
|
||||||
|
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
|
||||||
|
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
|
||||||
|
transfers per GDPR Article 46.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 10 */}
|
||||||
|
<section id="audits" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
|
||||||
|
Article 28 obligations, including:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA (publicly available)</li>
|
||||||
|
<li>Privacy Policy describing processing activities</li>
|
||||||
|
<li>Sub-processor list (Section 6 above)</li>
|
||||||
|
<li>Security measures documentation (available upon reasonable request)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer may audit Plunk's compliance with this DPA, subject to the following:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Audits limited to once per year unless required by supervisory authority</li>
|
||||||
|
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
|
||||||
|
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
|
||||||
|
<li>Audits conducted during business hours with minimal disruption to operations</li>
|
||||||
|
<li>Customer bears all costs of audit</li>
|
||||||
|
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
|
||||||
|
compliance documentation (SOC 2, ISO 27001, etc.) where available.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 11 */}
|
||||||
|
<section id="deletion" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When Customer deletes their Plunk account (via dashboard or by request):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All Personal Data is immediately deleted from production systems</li>
|
||||||
|
<li>No grace period or recovery window (deletion is permanent)</li>
|
||||||
|
<li>
|
||||||
|
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
|
||||||
|
</li>
|
||||||
|
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
|
||||||
|
<li>Backup data is not accessible or restorable after account deletion</li>
|
||||||
|
<li>Backups are automatically overwritten after retention period</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
|
||||||
|
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
|
||||||
|
<li>Legal requests: Data subject to legal holds retained as required by law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
|
||||||
|
before deleting account. Once deleted, data cannot be recovered.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 12 */}
|
||||||
|
<section id="liability" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
|
||||||
|
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
|
||||||
|
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
|
||||||
|
<li>Verifying Data Subject identity before fulfilling rights requests</li>
|
||||||
|
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
|
||||||
|
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
|
||||||
|
<li>
|
||||||
|
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
|
||||||
|
82)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
|
||||||
|
laws
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
|
||||||
|
28
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
|
||||||
|
fault. Each party is liable only for the damage caused by its own GDPR violation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 13 */}
|
||||||
|
<section id="term" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
|
||||||
|
the duration of the Terms of Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer deletes their Plunk account</li>
|
||||||
|
<li>Terms of Service are terminated</li>
|
||||||
|
<li>All Personal Data has been deleted per Section 11</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
|
||||||
|
necessary to fulfill their purpose.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
|
||||||
|
<li>Material changes will be notified via email 30 days in advance</li>
|
||||||
|
<li>Continued use of Plunk after changes constitutes acceptance</li>
|
||||||
|
<li>Check "Last Updated" date at top of page for version tracking</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Section */}
|
||||||
|
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
For questions about this DPA, data processing activities, or to exercise audit rights:
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
|
||||||
|
legal@useplunk.com with Data Subject's email address and nature of request.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Code2, Eye, Mail, Palette, Sparkles, Type, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Type className="h-5 w-5" />,
|
||||||
|
title: 'Visual WYSIWYG Editor',
|
||||||
|
description:
|
||||||
|
'Rich text editing with formatting toolbar. Bold, italic, headings, lists, links, images, and tables. No code required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-5 w-5" />,
|
||||||
|
title: 'Full HTML Editor',
|
||||||
|
description:
|
||||||
|
'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
title: 'Smart Mode Switching',
|
||||||
|
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" />,
|
||||||
|
title: 'Powerful Variables',
|
||||||
|
description:
|
||||||
|
'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Eye className="h-5 w-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" />,
|
||||||
|
title: 'Email-Safe HTML',
|
||||||
|
description: 'Automatic CSS inlining and email-client-friendly code generation. Yes, even in Outlook.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-6 w-6" />,
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Palette className="h-6 w-6" />,
|
||||||
|
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" />,
|
||||||
|
title: 'For Teams',
|
||||||
|
description:
|
||||||
|
'One tool for everyone. Developers can code, marketers can design, everyone can preview. Reusable templates across campaigns and workflows.',
|
||||||
|
example: 'Launch announcements → Feature updates → User engagement → Lifecycle emails',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default function EmailEditorFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk</title>
|
||||||
|
<meta
|
||||||
|
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: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."
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
|
>
|
||||||
|
<div className={'mb-6 inline-flex items-center gap-2 rounded-full 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`}
|
||||||
|
className={
|
||||||
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Try 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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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]}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
className={
|
||||||
|
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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'}>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Bell, Database, Inbox, Mail, Shield, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Database className="h-5 w-5" />,
|
||||||
|
title: 'Automatic Contact Capture',
|
||||||
|
description: 'Every sender is automatically added to your contact database with no manual data entry required.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-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" />,
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Bell className="h-5 w-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" />,
|
||||||
|
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" />,
|
||||||
|
title: 'Real-Time Processing',
|
||||||
|
description: 'Emails are processed instantly and can trigger workflows or webhooks in real-time.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" />,
|
||||||
|
title: 'Support Ticket Creation',
|
||||||
|
description:
|
||||||
|
'Automatically create support tickets when customers email [email protected]. 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" />,
|
||||||
|
title: 'Lead Capture from Email',
|
||||||
|
description:
|
||||||
|
'Receive emails at [email protected] 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" />,
|
||||||
|
title: 'Two-Way Conversations',
|
||||||
|
description:
|
||||||
|
'Let customers reply to your campaign emails and automatically trigger engagement workflows. Tag contacts as "engaged" when they respond.',
|
||||||
|
benefits: ['Build conversation history', 'Track engagement', 'Personalized responses'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default function InboundEmailFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Inbound Email - Receive & Process Incoming Emails | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="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:title" content="Inbound Email - Turn Incoming Emails into Automated Actions | Plunk" />
|
||||||
|
<meta
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
|
>
|
||||||
|
<div className={'mb-6 inline-flex items-center gap-2 rounded-full 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`}
|
||||||
|
className={
|
||||||
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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]}}
|
||||||
|
>
|
||||||
|
<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'}>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>
|
||||||
|
</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'}
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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 => (
|
||||||
|
<span
|
||||||
|
key={benefit}
|
||||||
|
className={'rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700'}
|
||||||
|
>
|
||||||
|
{benefit}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</motion.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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Filter, GitBranch, Mail, Target, TrendingUp, Users} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Filter className="h-5 w-5" />,
|
||||||
|
title: 'Dynamic Filtering',
|
||||||
|
description:
|
||||||
|
'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TrendingUp className="h-5 w-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" />,
|
||||||
|
title: 'Workflow Integration',
|
||||||
|
description:
|
||||||
|
'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Target className="h-5 w-5" />,
|
||||||
|
title: 'Campaign Targeting',
|
||||||
|
description:
|
||||||
|
'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Users className="h-5 w-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" />,
|
||||||
|
title: 'Behavior-Based',
|
||||||
|
description: 'Segment by email engagement - who opened, clicked, bounced, or never received your emails.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterExamples = [
|
||||||
|
{
|
||||||
|
title: 'Active Users',
|
||||||
|
description: 'Target users who signed up recently and are actively engaging',
|
||||||
|
filters: ['Created within 30 days', 'Opened email within 7 days', 'Custom field: plan equals "pro"'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Re-engagement Needed',
|
||||||
|
description: 'Find inactive users who need a nudge to come back. Sometimes they just need a reminder.',
|
||||||
|
filters: ['Last activity older than 60 days', 'Email sent but not opened', 'Subscribed equals true'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'High-Value Customers',
|
||||||
|
description: 'Identify your most valuable customers for special treatment',
|
||||||
|
filters: ['Custom field: totalSpent greater than 1000', 'Triggered event: purchase', 'Plan equals "enterprise"'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" />,
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <GitBranch className="h-6 w-6" />,
|
||||||
|
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" />,
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Audience Segmentation - Target the Right Contacts | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="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."
|
||||||
|
/>
|
||||||
|
<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."
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
|
>
|
||||||
|
<div className={'mb-6 inline-flex items-center gap-2 rounded-full 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`}
|
||||||
|
className={
|
||||||
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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'}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
className={
|
||||||
|
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Code2, Lock, Mail, Server, Settings, Shield} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Settings className="h-5 w-5" />,
|
||||||
|
title: 'Simple Configuration',
|
||||||
|
description: 'Quick setup with your project credentials. Works with any email client or application.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Lock className="h-5 w-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" />,
|
||||||
|
title: 'Universal Compatibility',
|
||||||
|
description:
|
||||||
|
'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application. Even that ancient email client from 2005.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server className="h-5 w-5" />,
|
||||||
|
title: 'Domain Validation',
|
||||||
|
description: 'Automatic verification that your sender domain is verified before accepting emails.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
title: 'Full Feature Support',
|
||||||
|
description:
|
||||||
|
'Attachments, custom headers, HTML emails, and multiple recipients. Send those cat memes with confidence.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-5 w-5" />,
|
||||||
|
title: 'Same Infrastructure',
|
||||||
|
description: 'SMTP emails use the same reliable delivery infrastructure as API emails with full tracking.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const comparisonData = [
|
||||||
|
{feature: 'Protocol', traditional: 'SMTP', plunkSMTP: 'SMTP', plunkAPI: 'HTTP/REST'},
|
||||||
|
{feature: 'Setup Complexity', traditional: 'Medium', plunkSMTP: 'Easy', plunkAPI: 'Easy'},
|
||||||
|
{feature: 'Tracking & Analytics', traditional: '✗', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||||
|
{feature: 'Works with Email Clients', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✗'},
|
||||||
|
{feature: 'Domain Verification', traditional: 'Manual', plunkSMTP: 'Automatic', plunkAPI: 'Automatic'},
|
||||||
|
{feature: 'Attachments', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||||
|
{feature: 'Template Support', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||||
|
{feature: 'Workflow Automation', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Settings className="h-6 w-6" />,
|
||||||
|
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.',
|
||||||
|
benefit: 'Zero code changes required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" />,
|
||||||
|
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" />,
|
||||||
|
title: 'Framework Compatibility',
|
||||||
|
description:
|
||||||
|
'Works with any framework or language that supports SMTP. Perfect for older systems or platforms without HTTP API support.',
|
||||||
|
benefit: 'Universal protocol support',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default function SMTPFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>SMTP Email Sending - Send via SMTP or API | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="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:title" content="SMTP Email Sending - Flexible Sending Options | Plunk" />
|
||||||
|
<meta
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
|
>
|
||||||
|
<div className={'mb-6 inline-flex items-center gap-2 rounded-full 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`}
|
||||||
|
className={
|
||||||
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get 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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className={'mx-auto max-w-4xl'}>
|
||||||
|
<div className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}>
|
||||||
|
<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-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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={'divide-y divide-neutral-200'}>
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
className={
|
||||||
|
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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'}>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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Clock, GitBranch, Mail, RefreshCw, UserPlus, Webhook, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
title: 'Event-Driven Triggers',
|
||||||
|
description: 'Start workflows automatically when users sign up, make a purchase, or perform any custom action.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-5 w-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" />,
|
||||||
|
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" />,
|
||||||
|
title: 'Conditional Logic',
|
||||||
|
description: 'Branch workflows based on user behavior, attributes, or engagement to personalize every journey.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Webhook className="h-5 w-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" />,
|
||||||
|
title: 'Re-entry Control',
|
||||||
|
description: 'Decide whether contacts can enter workflows multiple times or just once. No spam, just strategy.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <UserPlus className="h-6 w-6" />,
|
||||||
|
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" />,
|
||||||
|
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.',
|
||||||
|
example: 'Trigger on cart abandoned → Wait 1 hour → Send reminder → Wait 1 day → Send discount offer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <RefreshCw className="h-6 w-6" />,
|
||||||
|
title: 'Re-engagement Campaigns',
|
||||||
|
description:
|
||||||
|
'Win back inactive users with targeted campaigns based on their last activity and engagement patterns.',
|
||||||
|
example: 'Trigger on 30 days inactive → Check if opened last email → Yes: Send update / No: Send special offer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export default function WorkflowsFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Email Workflow Automation | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="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:title" content="Email Workflow Automation - Automate Your Email Marketing | Plunk" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
|
>
|
||||||
|
<div className={'mb-6 inline-flex items-center gap-2 rounded-full 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`}
|
||||||
|
className={
|
||||||
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
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>
|
||||||
|
))}
|
||||||
|
</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]}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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
|
||||||
|
className={
|
||||||
|
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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'}>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -224,7 +224,12 @@ export default function GuidesIndex() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -245,7 +250,7 @@ export default function GuidesIndex() {
|
|||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
Get started free
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock,
|
Clock,
|
||||||
Code2,
|
|
||||||
Globe,
|
Globe,
|
||||||
|
Inbox,
|
||||||
Lock,
|
Lock,
|
||||||
Mail,
|
Mail,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
@@ -72,17 +72,18 @@ const features = [
|
|||||||
{
|
{
|
||||||
icon: <Workflow className="h-5 w-5" />,
|
icon: <Workflow className="h-5 w-5" />,
|
||||||
title: 'Workflow Automation',
|
title: 'Workflow Automation',
|
||||||
description: 'Visual builder for complex email sequences with triggers, delays, and conditional logic.',
|
description:
|
||||||
|
'Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Users className="h-5 w-5" />,
|
icon: <Users className="h-5 w-5" />,
|
||||||
title: 'Dynamic Segments',
|
title: 'Dynamic Segments',
|
||||||
description: 'Real-time audience segmentation based on contact data and behavior.',
|
description: 'Real-time audience segmentation based on contact data and behavior. Less noise, more signal.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Mail className="h-5 w-5" />,
|
icon: <Mail className="h-5 w-5" />,
|
||||||
title: 'Campaign Management',
|
title: 'Campaign Management',
|
||||||
description: 'Broadcast emails with scheduling and performance tracking.',
|
description: 'Broadcast emails with scheduling and performance tracking. Send the right message at the right time.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <BarChart3 className="h-5 w-5" />,
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
@@ -90,14 +91,14 @@ const features = [
|
|||||||
description: 'Detailed metrics on opens, clicks, bounces, and conversions across campaigns.',
|
description: 'Detailed metrics on opens, clicks, bounces, and conversions across campaigns.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Code2 className="h-5 w-5" />,
|
icon: <Inbox className="h-5 w-5" />,
|
||||||
title: 'Developer API',
|
title: 'Inbound Email',
|
||||||
description: 'RESTful API with comprehensive documentation.',
|
description: 'Receive and process incoming emails with webhook notifications. Your inbox, automated.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Globe className="h-5 w-5" />,
|
icon: <Globe className="h-5 w-5" />,
|
||||||
title: 'Custom Domains',
|
title: 'Custom Domains',
|
||||||
description: 'Brand consistency with DKIM authentication and custom sending domains.',
|
description: 'Brand consistency with DKIM authentication and custom sending domains. Yes, even in Outlook.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -224,13 +225,36 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
>
|
>
|
||||||
<h1 className={'text-7xl font-bold tracking-tight text-neutral-900 sm:text-8xl lg:text-9xl'}>
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 10}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={
|
||||||
|
'mb-8 inline-flex flex-col sm:flex-row items-center gap-x-2 gap-y-1 rounded-full border border-neutral-200 bg-white px-4 py-2 text-sm shadow-sm'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={'flex items-center gap-2'}>
|
||||||
|
<svg className="h-4 w-4 text-neutral-700" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className={'font-medium text-neutral-700'}>5,000+ Stars on GitHub</span>
|
||||||
|
</span>
|
||||||
|
<span className={'hidden sm:inline text-neutral-300'}>·</span>
|
||||||
|
<span className={'text-neutral-500'}>AGPL-3.0 Open Source</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className={'text-7xl font-bold tracking-tight text-neutral-900 sm:text-8xl lg:text-9xl text-balance'}>
|
||||||
Open-Source
|
Open-Source
|
||||||
<br />
|
<br />
|
||||||
Email Platform
|
Email Platform
|
||||||
</h1>
|
</h1>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Open-source email automation. Build workflows, segment audiences, and send emails with a simple API.
|
Transactional emails, marketing campaigns, and workflow automation in one platform. Open-source,
|
||||||
|
self-hostable, $0.001 per email, no contact limits.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -269,14 +293,14 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-5xl text-center'}
|
className={'mx-auto max-w-5xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Replace Resend, SendGrid, Mailchimp, and More
|
Replace your email stack
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||||
The open-source alternative to proprietary email platforms
|
The open-source alternative to Resend, SendGrid, Mailchimp, and more
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-5'}>
|
<div className={'mt-16 grid grid-cols-2 gap-px bg-neutral-200 lg:grid-cols-5'}>
|
||||||
<Link href={'/vs/resend'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
|
<Link href={'/vs/resend'} className={'group bg-white p-10 transition hover:bg-neutral-50'}>
|
||||||
<div className={'flex flex-col items-center gap-4'}>
|
<div className={'flex flex-col items-center gap-4'}>
|
||||||
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
|
<div className={'text-2xl font-bold text-neutral-400 transition group-hover:text-neutral-900'}>
|
||||||
@@ -334,7 +358,7 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Most email tools weren't built to scale
|
Most email tools weren't built to scale
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -355,7 +379,9 @@ export default function Index() {
|
|||||||
<Clock className="h-5 w-5" />
|
<Clock className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Complex setup</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Complex setup</h3>
|
||||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Hours of configuration needed</p>
|
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||||
|
Most email platforms take days to configure. Plunk is up and running in under 5 minutes.
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -373,7 +399,9 @@ export default function Index() {
|
|||||||
<TrendingUp className="h-5 w-5" />
|
<TrendingUp className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Contact limits</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Contact limits</h3>
|
||||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Growth penalties</p>
|
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||||
|
Other platforms charge more as your list grows. Plunk stores unlimited contacts for free.
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -391,7 +419,9 @@ export default function Index() {
|
|||||||
<Lock className="h-5 w-5" />
|
<Lock className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Vendor lock-in</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Vendor lock-in</h3>
|
||||||
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>Closed source platforms</p>
|
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
|
||||||
|
Closed-source platforms own your stack. Plunk is AGPL-3.0 licensed and fully self-hostable.
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -405,7 +435,7 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Built for scale</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Built for scale</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to run email at any volume</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to run email at any volume</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -442,11 +472,12 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
One contact, unified across everything
|
One contact, complete history
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Every interaction flows into a single contact record with complete history
|
Every interaction flows into a single contact record. Transactional emails, campaigns, and workflows, all
|
||||||
|
tracked in one place.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -602,31 +633,40 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Open source, privacy first</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
|
Open source, privacy first
|
||||||
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Transparent codebase, privacy-focused infrastructure, and the option to self-host
|
AGPL-3.0 licensed, EU-hosted, and GDPR compliant. Inspect the code, self-host on your own infrastructure,
|
||||||
|
or use our cloud.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
<div className={'mt-16 grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||||
<div className={'bg-white p-12'}>
|
<div className={'bg-white p-12'}>
|
||||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
<div
|
||||||
<PackageOpen className={'h-7 w-7 text-neutral-900'} />
|
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||||
|
>
|
||||||
|
<PackageOpen className={'h-7 w-7'} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Open Source</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Open Source</h3>
|
||||||
<p className={'mt-2 text-sm text-neutral-600'}>AGPL-3.0 licensed</p>
|
<p className={'mt-2 text-sm text-neutral-600'}>AGPL-3.0 licensed</p>
|
||||||
<p className={'mt-4 text-xs text-neutral-500'}>4K+ stars on GitHub</p>
|
<p className={'mt-4 text-xs text-neutral-500'}>5K+ stars on GitHub</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={'bg-white p-12'}>
|
<div className={'bg-white p-12'}>
|
||||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
<div
|
||||||
<Shield className={'h-7 w-7 text-neutral-900'} />
|
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||||
|
>
|
||||||
|
<Shield className={'h-7 w-7'} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Privacy First</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Privacy First</h3>
|
||||||
<p className={'mt-2 text-sm text-neutral-600'}>EU hosted</p>
|
<p className={'mt-2 text-sm text-neutral-600'}>EU hosted</p>
|
||||||
<p className={'mt-4 text-xs text-neutral-500'}>GDPR compliant</p>
|
<p className={'mt-4 text-xs text-neutral-500'}>GDPR compliant</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={'bg-white p-12'}>
|
<div className={'bg-white p-12'}>
|
||||||
<div className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-100'}>
|
<div
|
||||||
<Globe className={'h-7 w-7 text-neutral-900'} />
|
className={'mx-auto flex h-14 w-14 items-center justify-center rounded-xl bg-neutral-900 text-white'}
|
||||||
|
>
|
||||||
|
<Globe className={'h-7 w-7'} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Self-Hostable</h3>
|
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>Self-Hostable</h3>
|
||||||
<p className={'mt-2 text-sm text-neutral-600'}>Deploy anywhere</p>
|
<p className={'mt-2 text-sm text-neutral-600'}>Deploy anywhere</p>
|
||||||
@@ -660,8 +700,12 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Simple, transparent pricing</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>Pay for what you use, nothing more</p>
|
Simple, transparent pricing
|
||||||
|
</h2>
|
||||||
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
|
Pay for what you use, nothing more. No surprises at scale.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className={'mt-20'}>
|
<div className={'mt-20'}>
|
||||||
<div className={'flex items-baseline justify-center gap-3'}>
|
<div className={'flex items-baseline justify-center gap-3'}>
|
||||||
@@ -735,7 +779,7 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
>
|
>
|
||||||
<div className={'mb-20 text-center'}>
|
<div className={'mb-20 text-center'}>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Trusted by the best</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Trusted by the best</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
@@ -748,9 +792,8 @@ export default function Index() {
|
|||||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'flex flex-col bg-white p-10'}
|
className={'flex flex-col bg-white p-10'}
|
||||||
>
|
>
|
||||||
<p className={'min-h-[3.5rem] text-sm leading-relaxed text-neutral-600'}>
|
<div className={'mb-2 font-serif text-5xl leading-none text-neutral-200'}>“</div>
|
||||||
“{t.testimonial}”
|
<p className={'text-sm leading-relaxed text-neutral-700'}>{t.testimonial}</p>
|
||||||
</p>
|
|
||||||
<div className={'mt-auto flex items-center gap-4 pt-6'}>
|
<div className={'mt-auto flex items-center gap-4 pt-6'}>
|
||||||
<div className={'relative h-12 w-12 overflow-hidden rounded-full'}>
|
<div className={'relative h-12 w-12 overflow-hidden rounded-full'}>
|
||||||
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
|
<Image src={t.image} alt={t.author} placeholder="blur" className={'object-cover'} />
|
||||||
@@ -767,7 +810,12 @@ export default function Index() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -775,9 +823,11 @@ export default function Index() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to get started?</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
|
Start sending in 5 minutes
|
||||||
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join thousands of businesses building better email experiences with Plunk
|
1,000 emails free every month. Then $0.001 per email. No contact limits, no surprises.
|
||||||
</p>
|
</p>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
|
|||||||
+251
-254
@@ -2,14 +2,63 @@ import {NextSeo} from 'next-seo';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Footer, Navbar} from '../components';
|
import {Footer, Navbar} from '../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
|
|
||||||
import {DASHBOARD_URI} from '../lib/constants';
|
import {DASHBOARD_URI} from '../lib/constants';
|
||||||
|
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';
|
import {GithubIcon} from 'lucide-react';
|
||||||
|
|
||||||
|
const includedFeatures = [
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-5 w-5" />,
|
||||||
|
title: 'Transactional emails',
|
||||||
|
description: 'API and SMTP delivery for receipts, password resets, and any event-driven email.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
title: 'Workflow automation',
|
||||||
|
description: 'Build event-triggered sequences with delays, conditions, and branching logic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
|
title: 'Campaign broadcasts',
|
||||||
|
description: 'Send newsletters and announcements to your full list or a targeted segment.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Users className="h-5 w-5" />,
|
||||||
|
title: 'Unlimited contacts',
|
||||||
|
description: 'Store as many contacts as you need. Growing your list never costs more.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-5 w-5" />,
|
||||||
|
title: 'Full API access',
|
||||||
|
description: 'REST API with SDKs for Node.js, Python, and more. Comprehensive documentation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
title: 'Custom domains',
|
||||||
|
description: 'Send from your own domain with DKIM, SPF, and DMARC set up automatically.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
title: 'Audience segmentation',
|
||||||
|
description: 'Dynamic segments built on behavior, attributes, and engagement data.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
|
title: 'Analytics & tracking',
|
||||||
|
description: 'Opens, clicks, bounces, and unsubscribes. Real data, no guessing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PackageOpen className="h-5 w-5" />,
|
||||||
|
title: 'Open source',
|
||||||
|
description: 'AGPL-3.0 licensed. Inspect the code, self-host it, or contribute to it.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default function Index() {
|
export default function Pricing() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
@@ -32,273 +81,221 @@ export default function Index() {
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
||||||
<main>
|
{/* Hero */}
|
||||||
<section className={'py-32'}>
|
<section className={'relative py-20 sm:py-32'}>
|
||||||
<div className={'mx-auto max-w-4xl text-center'}>
|
<div
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl">
|
className={
|
||||||
Simple, transparent pricing
|
'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%)]'
|
||||||
</h1>
|
}
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">Start free, pay only for what you use</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pricing Tiers */}
|
<motion.div
|
||||||
<div className="mx-auto mt-20 grid max-w-5xl gap-8 lg:grid-cols-2">
|
initial={{opacity: 0, y: 20}}
|
||||||
{/* Free Tier */}
|
animate={{opacity: 1, y: 0}}
|
||||||
<motion.div
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
initial={{opacity: 0, y: 20}}
|
className={'mx-auto max-w-4xl text-center'}
|
||||||
animate={{opacity: 1, y: 0}}
|
>
|
||||||
transition={{delay: 0.2}}
|
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl text-balance">
|
||||||
className="relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-neutral-900 via-neutral-800 to-neutral-900 p-10 shadow-2xl"
|
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>
|
||||||
|
</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'}
|
||||||
>
|
>
|
||||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
Start for free
|
||||||
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
</motion.a>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="relative flex flex-1 flex-col">
|
{/* Pay as you grow */}
|
||||||
<div className="mb-6 inline-block self-start rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
|
<motion.div
|
||||||
Free Forever
|
initial={{opacity: 0, y: 20}}
|
||||||
</div>
|
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'}> </p>
|
||||||
|
|
||||||
<div className="mb-2 flex items-baseline gap-2">
|
<ul className={'mt-8 flex-1 space-y-3'}>
|
||||||
<span className="text-6xl font-bold tracking-tight text-white">1,000</span>
|
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
|
||||||
<span className="text-xl font-medium text-white/60">emails</span>
|
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
|
||||||
</div>
|
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
|
||||||
<p className="mb-8 text-base text-white/60">per month</p>
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<ul className="flex-1 space-y-3">
|
<motion.a
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
whileHover={{scale: 1.02}}
|
||||||
<path
|
whileTap={{scale: 0.98}}
|
||||||
fillRule="evenodd"
|
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'}
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
>
|
||||||
clipRule="evenodd"
|
Get started
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Transactional emails</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Workflow automation</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Campaign broadcasts</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Custom domains</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Click & open tracking</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>No credit card required</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/60">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Plunk branding</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<motion.a
|
</motion.a>
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
</motion.div>
|
||||||
whileHover={{scale: 1.02}}
|
</div>
|
||||||
whileTap={{scale: 0.98}}
|
</section>
|
||||||
className="mt-8 block w-full rounded-lg border-2 border-white bg-white px-6 py-3 text-center font-semibold text-neutral-900 transition hover:bg-white/90"
|
|
||||||
>
|
{/* Every feature included */}
|
||||||
Start for free
|
<section className={'py-20'}>
|
||||||
</motion.a>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</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>
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Pay as you grow */}
|
{/* Self-host */}
|
||||||
<motion.div
|
<section className={'py-20'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<motion.div
|
||||||
animate={{opacity: 1, y: 0}}
|
initial={{opacity: 0, y: 20}}
|
||||||
transition={{delay: 0.3}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
className="relative flex flex-col overflow-hidden rounded-2xl border-2 border-neutral-200 bg-white p-10 shadow-lg"
|
viewport={{once: true}}
|
||||||
>
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
<div className="mb-6 inline-block self-start rounded-full bg-neutral-100 px-4 py-1.5 text-sm font-semibold text-neutral-900">
|
className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}
|
||||||
Pay as you grow
|
>
|
||||||
|
<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
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className={'text-2xl font-bold text-neutral-900'}>Run it on your own infrastructure</h2>
|
||||||
<div className="mb-2 flex items-baseline gap-2">
|
<p className={'mt-2 text-neutral-600'}>
|
||||||
<span className="text-6xl font-bold tracking-tight text-neutral-900">$0.001</span>
|
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose in minutes.
|
||||||
<span className="text-xl font-medium text-neutral-500">per email</span>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-8 text-base text-neutral-500">No contact limits, pay only for what you send</p>
|
<div className={'sm:ml-auto sm:pl-8'}>
|
||||||
|
<motion.button
|
||||||
<ul className="flex-1 space-y-3">
|
onClick={() => {
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
window.open('https://github.com/useplunk/plunk', '_blank');
|
||||||
<svg
|
}}
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Everything in Free</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>No Plunk branding</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Billing limits</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Unlimited emails</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<motion.a
|
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.02}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
className="mt-8 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center font-semibold text-white transition hover:bg-neutral-800"
|
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'}
|
||||||
>
|
>
|
||||||
Start for free
|
<GithubIcon size={18} />
|
||||||
</motion.a>
|
View on GitHub
|
||||||
</motion.div>
|
</motion.button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features Grid */}
|
|
||||||
<div className="mx-auto mt-24 max-w-5xl">
|
|
||||||
<h2 className="mb-12 text-center text-2xl font-bold text-neutral-900">Everything included</h2>
|
|
||||||
<div className="grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited emails</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
|
||||||
Send unlimited transactional, campaign, and automated emails
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited contacts</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
|
||||||
No limits on your audience size. Grow without restrictions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Custom domains</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Send from your own domain with full authentication</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Analytics & tracking</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Detailed metrics on opens, clicks, and conversions</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Workflow automation</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Build complex email sequences with visual editor</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">API access</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Full REST API with comprehensive documentation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 overflow-hidden rounded-lg border border-neutral-200">
|
|
||||||
<div className="flex flex-col items-center space-y-6 p-10 sm:flex-row sm:space-y-0">
|
|
||||||
<div className="flex-1 text-center sm:text-left">
|
|
||||||
<h2 className={'text-2xl font-bold text-neutral-900'}>Self-host</h2>
|
|
||||||
<p className={'mt-2 text-neutral-600'}>
|
|
||||||
Host Plunk on your own infrastructure. The perfect solution for when you require full control.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className={'sm:ml-auto'}>
|
|
||||||
<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={20} />
|
|
||||||
View GitHub
|
|
||||||
</motion.button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</motion.div>
|
||||||
</main>
|
</section>
|
||||||
</div>
|
|
||||||
|
{/* 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'}
|
||||||
|
>
|
||||||
|
<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'}
|
||||||
|
>
|
||||||
|
Self-host for free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+1149
-8766
File diff suppressed because it is too large
Load Diff
+681
-4090
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@ export default function ToolsIndex() {
|
|||||||
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
|
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Free tools for
|
Free tools for
|
||||||
<br />
|
<br />
|
||||||
email developers
|
email developers
|
||||||
@@ -114,7 +114,7 @@ export default function ToolsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Available Tools</h2>
|
<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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to work with emails</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export default function ToolsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why use these tools?</h2>
|
<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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export default function ToolsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Need production-grade email tools?</h2>
|
<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'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
These free tools are great for development, but Plunk offers so much more: templates, scheduling,
|
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.
|
automation, analytics, and deliverability optimization. Start free, scale as you grow.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default function MarkdownToEmail() {
|
|||||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Markdown to Email
|
Markdown to Email
|
||||||
<br />
|
<br />
|
||||||
HTML Converter
|
HTML Converter
|
||||||
@@ -134,7 +134,7 @@ export default function MarkdownToEmail() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to send great emails?</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to send great emails?</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
|
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
|
||||||
and deliverability. Start free, no credit card required.
|
and deliverability. Start free, no credit card required.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {DASHBOARD_URI} from '../../lib/constants';
|
|||||||
import React, {useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, CheckCircle, Loader2, Search} from 'lucide-react';
|
import {ArrowRight, CheckCircle, Loader2, Search} from 'lucide-react';
|
||||||
import {type EmailVerificationResult as VerificationResult, verifyEmail} from '../../lib/emailVerification';
|
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||||
|
import {verifyEmail} from '../../lib/emailVerification';
|
||||||
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
||||||
import {Button, Input} from '@plunk/ui';
|
import {Button, Input} from '@plunk/ui';
|
||||||
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
|
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
|
||||||
@@ -71,7 +72,7 @@ export default function VerifyEmailPage() {
|
|||||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Email Verification
|
Email Verification
|
||||||
<br />
|
<br />
|
||||||
Tool
|
Tool
|
||||||
@@ -158,7 +159,7 @@ export default function VerifyEmailPage() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why verify email addresses?</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why verify email addresses?</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>
|
<p className={'mt-4 text-lg text-neutral-600'}>
|
||||||
Email verification helps improve deliverability and protect your sender reputation.
|
Email verification helps improve deliverability and protect your sender reputation.
|
||||||
</p>
|
</p>
|
||||||
@@ -200,7 +201,7 @@ export default function VerifyEmailPage() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Ready for production-grade email verification?
|
Ready for production-grade email verification?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose ActiveCampaign over Plunk?',
|
question: 'When should I choose ActiveCampaign over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity—perfect for developers who want to integrate email into their product without CRM overhead.',
|
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity. It is built for developers who want to integrate email into their product without CRM overhead.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What is the price difference between Plunk and ActiveCampaign?',
|
question: 'What is the price difference between Plunk and ActiveCampaign?',
|
||||||
answer:
|
answer:
|
||||||
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing—you only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing. You only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
||||||
@@ -40,7 +40,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Is migration from ActiveCampaign to Plunk difficult?',
|
question: 'Is migration from ActiveCampaign to Plunk difficult?',
|
||||||
answer:
|
answer:
|
||||||
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward—export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward: export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What ActiveCampaign features does Plunk not have?',
|
question: 'What ActiveCampaign features does Plunk not have?',
|
||||||
@@ -94,7 +94,7 @@ export default function ActiveCampaignComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ActiveCampaign</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ActiveCampaign</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for ActiveCampaign
|
for ActiveCampaign
|
||||||
@@ -141,7 +141,7 @@ export default function ActiveCampaignComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Enterprise Features Without Enterprise Prices
|
Enterprise Features Without Enterprise Prices
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
|
||||||
@@ -233,7 +233,7 @@ export default function ActiveCampaignComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Automation Without Complexity</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Automation Without Complexity</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
@@ -374,7 +374,7 @@ export default function ActiveCampaignComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
|
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
|
||||||
@@ -384,7 +384,12 @@ export default function ActiveCampaignComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -392,7 +397,7 @@ export default function ActiveCampaignComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Get email automation without the bloat
|
Get email automation without the bloat
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
@@ -405,10 +410,13 @@ export default function ActiveCampaignComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose Bento over Plunk?',
|
question: 'When should I choose Bento over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.",
|
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat. It's built specifically for integrating email into your product.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What is the pricing difference between Plunk and Bento?',
|
question: 'What is the pricing difference between Plunk and Bento?',
|
||||||
answer:
|
answer:
|
||||||
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed—you only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed. You only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle email automation like Bento?',
|
question: 'Can Plunk handle email automation like Bento?',
|
||||||
answer:
|
answer:
|
||||||
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns—all the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns. All the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How does the developer experience compare?',
|
question: 'How does the developer experience compare?',
|
||||||
@@ -94,7 +94,7 @@ export default function BentoComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Bento</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Bento</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Bento
|
for Bento
|
||||||
@@ -141,7 +141,7 @@ export default function BentoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Focused Email vs All-in-One Complexity
|
Focused Email vs All-in-One Complexity
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
|
||||||
@@ -233,7 +233,7 @@ export default function BentoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Developer-First Email Platform</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Developer-First Email Platform</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
@@ -374,7 +374,7 @@ export default function BentoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Bento" rows={comparisonData} />
|
<ComparisonTable competitorName="Bento" rows={comparisonData} />
|
||||||
@@ -384,7 +384,12 @@ export default function BentoComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -392,7 +397,7 @@ export default function BentoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get focused email automation</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get focused email automation</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
|
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
|
||||||
required.
|
required.
|
||||||
@@ -403,10 +408,13 @@ export default function BentoComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function BrevoComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Brevo</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Brevo</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Brevo
|
for Brevo
|
||||||
@@ -140,7 +140,7 @@ export default function BrevoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pricing That Scales With You</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pricing That Scales With You</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ export default function BrevoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Brevo</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Brevo</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ export default function BrevoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -383,7 +383,12 @@ export default function BrevoComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -391,7 +396,7 @@ export default function BrevoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch to simplicity</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch to simplicity</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
|
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
|
||||||
</p>
|
</p>
|
||||||
@@ -401,10 +406,13 @@ export default function BrevoComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'What is the pricing difference between Plunk and ConvertKit?',
|
question: 'What is the pricing difference between Plunk and ConvertKit?',
|
||||||
answer:
|
answer:
|
||||||
'ConvertKit charges based on subscriber count—your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
'ConvertKit charges based on subscriber count. Your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle creator workflows like ConvertKit?',
|
question: 'Can Plunk handle creator workflows like ConvertKit?',
|
||||||
answer:
|
answer:
|
||||||
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features—it's focused purely on email delivery and automation.",
|
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features. It's focused purely on email delivery and automation.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does Plunk have landing pages like ConvertKit?',
|
question: 'Does Plunk have landing pages like ConvertKit?',
|
||||||
@@ -57,7 +57,7 @@ export default function ConvertkitComparison() {
|
|||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="ConvertKit Alternative for Developers | Plunk"
|
title="ConvertKit Alternative for Developers | Plunk"
|
||||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation—no landing page bloat."
|
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation. No landing page bloat."
|
||||||
canonical="https://next.useplunk.com/vs/convertkit"
|
canonical="https://next.useplunk.com/vs/convertkit"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||||
@@ -94,7 +94,7 @@ export default function ConvertkitComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ConvertKit</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ConvertKit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for ConvertKit
|
for ConvertKit
|
||||||
@@ -141,7 +141,7 @@ export default function ConvertkitComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Pay for Emails, Not Subscribers</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pay for Emails, Not Subscribers</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ export default function ConvertkitComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Built for Developers, Not Creators</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Built for Developers, Not Creators</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
@@ -372,7 +372,7 @@ export default function ConvertkitComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
|
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
|
||||||
@@ -382,7 +382,12 @@ export default function ConvertkitComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -390,7 +395,7 @@ export default function ConvertkitComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Ready for a developer-first email platform?
|
Ready for a developer-first email platform?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
@@ -403,10 +408,13 @@ export default function ConvertkitComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -89,15 +89,14 @@ export default function CustomerioComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Customer.io</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Customer.io</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Customer.io
|
for Customer.io
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Customer.io is powerful but complex. Plunk gives developers the automation they need without the
|
Customer.io starts at $100/month and takes weeks to set up. Plunk delivers the same event-driven automation in an open-source platform you can be sending from in under 5 minutes.
|
||||||
enterprise overhead.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -136,7 +135,7 @@ export default function CustomerioComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Simple Pricing That Scales</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Simple Pricing That Scales</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -226,7 +225,7 @@ export default function CustomerioComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
|
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
|
||||||
@@ -241,7 +240,7 @@ export default function CustomerioComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Key Advantages</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Key Advantages</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -378,7 +377,12 @@ export default function CustomerioComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -386,7 +390,7 @@ export default function CustomerioComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
1,000 emails/month free. No credit card required. Developer-friendly from day one.
|
1,000 emails/month free. No credit card required. Developer-friendly from day one.
|
||||||
</p>
|
</p>
|
||||||
@@ -396,10 +400,13 @@ export default function CustomerioComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href={WIKI_URI}
|
href={WIKI_URI}
|
||||||
|
|||||||
@@ -131,15 +131,14 @@ export default function CompetitorsIndex() {
|
|||||||
<span className={'text-sm text-neutral-600'}>Email Platform Comparisons</span>
|
<span className={'text-sm text-neutral-600'}>Email Platform Comparisons</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Plunk vs the
|
Plunk vs the
|
||||||
<br />
|
<br />
|
||||||
competition
|
competition
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Compare Plunk with leading email platforms. See why developers choose our open-source alternative for
|
Most email platforms charge by the contact, lock you in, and split transactional from marketing. Plunk does all three in one open-source platform at $0.001 per email.
|
||||||
transactional emails plus marketing features.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -178,7 +177,7 @@ export default function CompetitorsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Compare Plunk with Industry Leaders
|
Compare Plunk with Industry Leaders
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
|
||||||
@@ -223,7 +222,7 @@ export default function CompetitorsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -237,11 +236,10 @@ export default function CompetitorsIndex() {
|
|||||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'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" />
|
<Mail className="h-8 w-8 text-neutral-900 mb-4" />
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
|
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||||
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns,
|
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns, automation, and segmentation. All in one platform.
|
||||||
automation, and segmentation—all in one platform.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -254,7 +252,7 @@ export default function CompetitorsIndex() {
|
|||||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Code className="h-8 w-8 text-green-500 mb-4" />
|
<Code className="h-8 w-8 text-neutral-900 mb-4" />
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
|
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||||
AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and
|
AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and
|
||||||
@@ -271,7 +269,7 @@ export default function CompetitorsIndex() {
|
|||||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<DollarSign className="h-8 w-8 text-yellow-500 mb-4" />
|
<DollarSign className="h-8 w-8 text-neutral-900 mb-4" />
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Simple Pricing</h3>
|
<h3 className={'text-2xl font-bold text-neutral-900'}>Simple Pricing</h3>
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
||||||
Pay-as-you-go pricing with all features included. No separate charges for transactional vs marketing
|
Pay-as-you-go pricing with all features included. No separate charges for transactional vs marketing
|
||||||
@@ -282,7 +280,12 @@ export default function CompetitorsIndex() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -290,7 +293,7 @@ export default function CompetitorsIndex() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to try Plunk?</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to try Plunk?</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
|
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
|
||||||
</p>
|
</p>
|
||||||
@@ -300,10 +303,13 @@ export default function CompetitorsIndex() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose Klaviyo over Plunk?',
|
question: 'When should I choose Klaviyo over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag—perfect for developers and growing businesses.',
|
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag. It is built for developers and growing businesses who want the capabilities without the cost.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How much cheaper is Plunk compared to Klaviyo?',
|
question: 'How much cheaper is Plunk compared to Klaviyo?',
|
||||||
answer:
|
answer:
|
||||||
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing—you only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing. You only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle e-commerce emails?',
|
question: 'Can Plunk handle e-commerce emails?',
|
||||||
@@ -94,15 +94,14 @@ export default function KlaviyoComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Klaviyo</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Klaviyo</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Klaviyo
|
for Klaviyo
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers email automation for any use
|
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers the same email automation, including full e-commerce support, at a fraction of the cost. No hidden fees, no contact-based pricing.
|
||||||
case—including e-commerce—at a fraction of the cost. No hidden fees, no contact-based pricing.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -141,7 +140,7 @@ export default function KlaviyoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
E-commerce Email Without the Premium Price
|
E-commerce Email Without the Premium Price
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||||
@@ -233,7 +232,7 @@ export default function KlaviyoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
E-commerce Email, Developer-Friendly
|
E-commerce Email, Developer-Friendly
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -376,7 +375,7 @@ export default function KlaviyoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
|
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
|
||||||
@@ -386,7 +385,12 @@ export default function KlaviyoComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -394,7 +398,7 @@ export default function KlaviyoComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
E-commerce email at a fraction of the cost
|
E-commerce email at a fraction of the cost
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
@@ -407,10 +411,13 @@ export default function KlaviyoComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Does Plunk have the same modern features as Loops?',
|
question: 'Does Plunk have the same modern features as Loops?',
|
||||||
answer:
|
answer:
|
||||||
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API—just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API, just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How easy is it to migrate from Loops to Plunk?',
|
question: 'How easy is it to migrate from Loops to Plunk?',
|
||||||
@@ -93,7 +93,7 @@ export default function LoopsComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Loops</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Loops</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Loops
|
for Loops
|
||||||
@@ -140,7 +140,7 @@ export default function LoopsComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Transparent Pricing vs Vendor Lock-In
|
Transparent Pricing vs Vendor Lock-In
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
|
||||||
@@ -232,7 +232,7 @@ export default function LoopsComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Loops</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Loops</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -358,8 +358,7 @@ export default function LoopsComparison() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All Features Included</h3>
|
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All Features Included</h3>
|
||||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||||
Transactional emails, campaigns, workflows, segmentation—all included. No artificial feature gating
|
Transactional emails, campaigns, workflows, segmentation. All included. No artificial feature gating based on your plan.
|
||||||
based on your plan.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,7 +373,7 @@ export default function LoopsComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -385,7 +384,12 @@ export default function LoopsComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -393,7 +397,7 @@ export default function LoopsComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Switch to open source</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Switch to open source</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
|
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
|
||||||
required.
|
required.
|
||||||
@@ -404,10 +408,13 @@ export default function LoopsComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function MailchimpComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailchimp</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailchimp</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Mailchimp
|
for Mailchimp
|
||||||
@@ -141,7 +141,7 @@ export default function MailchimpComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ export default function MailchimpComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Developers Choose Plunk</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Developers Choose Plunk</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
@@ -367,7 +367,7 @@ export default function MailchimpComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
|
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
|
||||||
@@ -377,7 +377,12 @@ export default function MailchimpComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -385,7 +390,7 @@ export default function MailchimpComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Ready for a better developer experience?
|
Ready for a better developer experience?
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
@@ -398,10 +403,13 @@ export default function MailchimpComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Is Plunk more expensive than MailerLite?',
|
question: 'Is Plunk more expensive than MailerLite?',
|
||||||
answer:
|
answer:
|
||||||
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability—Plunk only charges for actual usage.',
|
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability: Plunk only charges for actual usage.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I self-host Plunk unlike MailerLite?',
|
question: 'Can I self-host Plunk unlike MailerLite?',
|
||||||
@@ -94,7 +94,7 @@ export default function MailerliteComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs MailerLite</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs MailerLite</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for MailerLite
|
for MailerLite
|
||||||
@@ -141,7 +141,7 @@ export default function MailerliteComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Open Source Meets Developer Experience
|
Open Source Meets Developer Experience
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
|
||||||
@@ -233,7 +233,7 @@ export default function MailerliteComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
The Truly Developer-First Alternative
|
The Truly Developer-First Alternative
|
||||||
</h2>
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -376,7 +376,7 @@ export default function MailerliteComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
|
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
|
||||||
@@ -386,7 +386,12 @@ export default function MailerliteComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -394,7 +399,7 @@ export default function MailerliteComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
|
||||||
Experience true developer-first email
|
Experience true developer-first email
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
@@ -407,10 +412,13 @@ export default function MailerliteComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -89,15 +89,14 @@ export default function MailgunComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailgun</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailgun</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Mailgun
|
for Mailgun
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Mailgun is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
Mailgun only handles transactional email. Plunk adds marketing campaigns, workflow automation, and segmentation on top, all open-source and self-hostable at $0.001 per email.
|
||||||
open-source.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -136,7 +135,7 @@ export default function MailgunComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -226,7 +225,7 @@ export default function MailgunComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
|
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
|
||||||
@@ -241,7 +240,7 @@ export default function MailgunComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -376,7 +375,12 @@ export default function MailgunComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -384,7 +388,7 @@ export default function MailgunComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
||||||
</p>
|
</p>
|
||||||
@@ -394,10 +398,13 @@ export default function MailgunComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href={WIKI_URI}
|
href={WIKI_URI}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export default function PostmarkComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Postmark</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Postmark</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Postmark
|
for Postmark
|
||||||
@@ -137,7 +137,7 @@ export default function PostmarkComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>One Platform for All Your Emails</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>One Platform for All Your Emails</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ export default function PostmarkComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over Postmark</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Postmark</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -270,7 +270,7 @@ export default function PostmarkComparison() {
|
|||||||
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
|
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
|
||||||
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>
|
||||||
Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads,
|
Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads,
|
||||||
re-engage customers—all automated.
|
re-engage customers. All automated.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -369,7 +369,7 @@ export default function PostmarkComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -380,7 +380,12 @@ export default function PostmarkComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -388,7 +393,7 @@ export default function PostmarkComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get more from your email platform</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get more from your email platform</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
|
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
|
||||||
free.
|
free.
|
||||||
@@ -399,10 +404,13 @@ export default function PostmarkComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -89,15 +89,14 @@ export default function ResendComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Resend</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Resend</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Resend
|
for Resend
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
||||||
Resend is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
Resend is transactional-only. Plunk gives you transactional emails, marketing campaigns, and workflow automation in a single open-source platform. No second tool, no second bill.
|
||||||
open-source. Choose based on what you need.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
||||||
@@ -136,7 +135,7 @@ export default function ResendComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -226,7 +225,7 @@ export default function ResendComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Resend" rows={comparisonData} />
|
<ComparisonTable competitorName="Resend" rows={comparisonData} />
|
||||||
@@ -241,7 +240,7 @@ export default function ResendComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>What Plunk Adds</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -376,7 +375,12 @@ export default function ResendComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -384,7 +388,7 @@ export default function ResendComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
||||||
</p>
|
</p>
|
||||||
@@ -394,10 +398,13 @@ export default function ResendComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href={WIKI_URI}
|
href={WIKI_URI}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function SendGridComparison() {
|
|||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs SendGrid</span>
|
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs SendGrid</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for SendGrid
|
for SendGrid
|
||||||
@@ -140,7 +140,7 @@ export default function SendGridComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>The Pricing Model That Makes Sense</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ export default function SendGridComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-20 text-center'}
|
className={'mb-20 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk Over SendGrid</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over SendGrid</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -372,7 +372,7 @@ export default function SendGridComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-16 text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Feature-by-Feature Comparison</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -383,7 +383,12 @@ export default function SendGridComparison() {
|
|||||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -391,7 +396,7 @@ export default function SendGridComparison() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'mx-auto max-w-3xl text-center'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch today</h2>
|
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch today</h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<p className={'mt-6 text-lg text-neutral-600'}>
|
||||||
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
|
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
|
||||||
</p>
|
</p>
|
||||||
@@ -401,10 +406,13 @@ export default function SendGridComparison() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Start free trial
|
<span className={'flex items-center gap-2'}>
|
||||||
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</span>
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plunk/db": "*",
|
"@plunk/db": "*",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"mailparser": "^3.7.1",
|
"mailparser": "^3.9.3",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
"smtp-server": "^3.13.4"
|
"smtp-server": "^3.13.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"nuqs": "^2.7.3",
|
"nuqs": "^2.7.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setActivities([]);
|
// Don't clear activities immediately - we'll do a smart merge
|
||||||
|
// This preserves React component instances and their local state
|
||||||
setNextCursor(undefined);
|
setNextCursor(undefined);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
}
|
}
|
||||||
@@ -58,11 +59,25 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
|
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
// Append to existing activities
|
// Append to existing activities (pagination)
|
||||||
setActivities(prev => [...prev, ...result.data]);
|
setActivities(prev => [...prev, ...result.data]);
|
||||||
} else {
|
} else {
|
||||||
// Replace activities
|
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||||
setActivities(result.data);
|
setActivities(prev => {
|
||||||
|
// If no previous activities, just use new data
|
||||||
|
if (prev.length === 0) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing activities by ID for fast lookup
|
||||||
|
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||||
|
|
||||||
|
// For each new activity, reuse existing object if ID matches (preserves React component instances)
|
||||||
|
// Otherwise use new object. This maintains correct ordering while preserving component state.
|
||||||
|
return result.data.map(newActivity =>
|
||||||
|
existingMap.get(newActivity.id) ?? newActivity
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setNextCursor(result.cursor);
|
setNextCursor(result.cursor);
|
||||||
@@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
|
|
||||||
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
||||||
|
|
||||||
setUpcomingActivities(result.activities);
|
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||||
|
setUpcomingActivities(prev => {
|
||||||
|
// If no previous activities, just use new data
|
||||||
|
if (prev.length === 0) {
|
||||||
|
return result.activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing activities by ID for fast lookup
|
||||||
|
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||||
|
|
||||||
|
// For each new activity, reuse existing object if ID matches
|
||||||
|
return result.activities.map(newActivity =>
|
||||||
|
existingMap.get(newActivity.id) ?? newActivity
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching upcoming activities:', err);
|
console.error('Error fetching upcoming activities:', err);
|
||||||
// Don't set error state for upcoming - just fail silently
|
// Don't set error state for upcoming - just fail silently
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Eye,
|
Eye,
|
||||||
|
Inbox,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
Send,
|
Send,
|
||||||
|
ShieldAlert,
|
||||||
Workflow,
|
Workflow,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -95,7 +97,15 @@ function getUpcomingTime(date: Date): string {
|
|||||||
* Check if an activity is an email activity
|
* Check if an activity is an email activity
|
||||||
*/
|
*/
|
||||||
function isEmailActivity(type: string): boolean {
|
function isEmailActivity(type: string): boolean {
|
||||||
return ['email.sent', 'email.delivered', 'email.opened', 'email.clicked', 'email.bounced'].includes(type);
|
return [
|
||||||
|
'email.sent',
|
||||||
|
'email.delivered',
|
||||||
|
'email.received',
|
||||||
|
'email.opened',
|
||||||
|
'email.clicked',
|
||||||
|
'email.bounced',
|
||||||
|
'email.complaint',
|
||||||
|
].includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
@@ -173,6 +183,19 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'email.received':
|
||||||
|
return {
|
||||||
|
icon: Inbox,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-100',
|
||||||
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
|
||||||
|
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
|
||||||
|
badge: {
|
||||||
|
label: 'Received',
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case 'email.opened':
|
case 'email.opened':
|
||||||
return {
|
return {
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
@@ -226,6 +249,23 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'email.complaint':
|
||||||
|
return {
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bgColor: 'bg-red-100',
|
||||||
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
|
||||||
|
description: metadata.campaignName
|
||||||
|
? `Campaign: ${String(metadata.campaignName)}`
|
||||||
|
: metadata.workflowName
|
||||||
|
? `Workflow: ${String(metadata.workflowName)}`
|
||||||
|
: 'Recipient marked as spam',
|
||||||
|
badge: {
|
||||||
|
label: 'Complaint',
|
||||||
|
variant: 'destructive',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case 'workflow.started':
|
case 'workflow.started':
|
||||||
return {
|
return {
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: null,
|
workflows: null,
|
||||||
campaigns: null,
|
campaigns: null,
|
||||||
transactional: null,
|
transactional: null,
|
||||||
|
inbound: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: limitsData.workflows.limit,
|
workflows: limitsData.workflows.limit,
|
||||||
campaigns: limitsData.campaigns.limit,
|
campaigns: limitsData.campaigns.limit,
|
||||||
transactional: limitsData.transactional.limit,
|
transactional: limitsData.transactional.limit,
|
||||||
|
inbound: limitsData.inbound.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [limitsData, form]);
|
}, [limitsData, form]);
|
||||||
@@ -121,6 +123,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: limitsData.workflows.limit,
|
workflows: limitsData.workflows.limit,
|
||||||
campaigns: limitsData.campaigns.limit,
|
campaigns: limitsData.campaigns.limit,
|
||||||
transactional: limitsData.transactional.limit,
|
transactional: limitsData.transactional.limit,
|
||||||
|
inbound: limitsData.inbound.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -217,6 +220,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
usage={limitsData.transactional}
|
usage={limitsData.transactional}
|
||||||
currency={limitsData.currency}
|
currency={limitsData.currency}
|
||||||
/>
|
/>
|
||||||
|
<UsageDisplay category="Inbound" usage={limitsData.inbound} currency={limitsData.currency} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -322,6 +326,36 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="inbound"
|
||||||
|
render={({field}) => {
|
||||||
|
const estimatedCost =
|
||||||
|
limitsData?.currency && field.value
|
||||||
|
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Inbound Emails Limit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum inbound emails per month. Leave empty for unlimited.
|
||||||
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type {Contact} from '@plunk/db';
|
||||||
|
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||||
|
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
||||||
|
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
||||||
|
import {useEffect, useRef, useState} from 'react';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
interface ContactPickerProps {
|
||||||
|
/** Currently selected emails */
|
||||||
|
selected: string[];
|
||||||
|
/** Called when selection changes */
|
||||||
|
onChange: (emails: string[]) => void;
|
||||||
|
/** Emails already in the segment (shown as disabled) */
|
||||||
|
existing?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searchable multi-select contact picker backed by the /contacts API.
|
||||||
|
* Only fetches when the user types (safe for large contact lists).
|
||||||
|
*/
|
||||||
|
export function ContactPicker({
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
existing = [],
|
||||||
|
placeholder = 'Search contacts...',
|
||||||
|
}: ContactPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Only fetch when there's a search term — avoids loading all contacts on open
|
||||||
|
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||||
|
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||||
|
{revalidateOnFocus: false},
|
||||||
|
);
|
||||||
|
|
||||||
|
const contacts = data?.data ?? [];
|
||||||
|
|
||||||
|
const toggle = (email: string) => {
|
||||||
|
if (selected.includes(email)) {
|
||||||
|
onChange(selected.filter(e => e !== email));
|
||||||
|
} else {
|
||||||
|
onChange([...selected, email]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="Type an email to search..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||||
|
{debouncedSearch.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||||
|
) : isLoading ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||||
|
) : (
|
||||||
|
contacts.map(contact => {
|
||||||
|
const isSelected = selected.includes(contact.email);
|
||||||
|
const isExisting = existing.includes(contact.email);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={contact.id}
|
||||||
|
type="button"
|
||||||
|
disabled={isExisting}
|
||||||
|
onClick={() => toggle(contact.email)}
|
||||||
|
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||||
|
>
|
||||||
|
{contact.subscribed ? (
|
||||||
|
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||||
|
{isExisting && (
|
||||||
|
<span className="text-xs text-neutral-400 shrink-0">already member</span>
|
||||||
|
)}
|
||||||
|
{isSelected && !isExisting && (
|
||||||
|
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Selected chips */}
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selected.map(email => (
|
||||||
|
<span
|
||||||
|
key={email}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||||
|
>
|
||||||
|
{email}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||||
|
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||||
|
aria-label={`Remove ${email}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
|
import {WIKI_URI} from '../lib/constants';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
FileText,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -72,6 +74,11 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
const mobileProjectMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const mobileUserMenuRef = useRef<HTMLDivElement>(null);
|
const mobileUserMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sort projects alphabetically by name
|
||||||
|
const sortedProjects = useMemo(() => {
|
||||||
|
return [...availableProjects].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [availableProjects]);
|
||||||
|
|
||||||
// Handle click outside for project menu
|
// Handle click outside for project menu
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
@@ -177,8 +184,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
{showProjectMenu && (
|
{showProjectMenu && (
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
|
||||||
{availableProjects.map(project => (
|
{sortedProjects.map(project => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
@@ -187,14 +194,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
setActiveProject(project);
|
setActiveProject(project);
|
||||||
setShowProjectMenu(false);
|
setShowProjectMenu(false);
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium">
|
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||||
{project.name.charAt(0).toUpperCase()}
|
{project.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||||
{activeProject?.id === project.id && (
|
{activeProject?.id === project.id && (
|
||||||
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900" />
|
<div className="ml-auto h-1.5 w-1.5 rounded-full bg-neutral-900 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -202,7 +209,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<Link
|
<Link
|
||||||
href="/projects/create"
|
href="/projects/create"
|
||||||
onClick={() => setShowProjectMenu(false)}
|
onClick={() => setShowProjectMenu(false)}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
<span>Create project</span>
|
<span>Create project</span>
|
||||||
@@ -246,6 +253,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Settings & User Menu */}
|
{/* Settings & User Menu */}
|
||||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||||
|
<a
|
||||||
|
href={WIKI_URI}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={() => setShowMobileMenu(false)}
|
onClick={() => setShowMobileMenu(false)}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form';
|
|||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {DomainSchemas} from '@plunk/shared';
|
import {DomainSchemas} from '@plunk/shared';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -22,7 +20,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {Check, CheckCircle2, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
||||||
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
||||||
|
|
||||||
interface DomainsSettingsProps {
|
interface DomainsSettingsProps {
|
||||||
@@ -34,6 +33,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
const {addDomain} = useAddDomain();
|
const {addDomain} = useAddDomain();
|
||||||
const {checkVerification} = useCheckDomainVerification();
|
const {checkVerification} = useCheckDomainVerification();
|
||||||
const {removeDomain} = useRemoveDomain();
|
const {removeDomain} = useRemoveDomain();
|
||||||
|
const {data: config} = useConfig();
|
||||||
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -47,6 +47,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
|
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
|
||||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
|
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
|
||||||
|
const [expandedDomains, setExpandedDomains] = useState<{[key: string]: boolean}>({});
|
||||||
|
|
||||||
const form = useForm<{domain: string}>({
|
const form = useForm<{domain: string}>({
|
||||||
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
|
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
|
||||||
@@ -86,6 +87,28 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [lastVerificationCheck]);
|
}, [lastVerificationCheck]);
|
||||||
|
|
||||||
|
// Auto-expand unverified domains on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (domains && domains.length > 0) {
|
||||||
|
const unverifiedDomains = domains
|
||||||
|
.filter(d => !d.verified)
|
||||||
|
.reduce(
|
||||||
|
(acc, d) => {
|
||||||
|
acc[d.id] = true;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as {[key: string]: boolean},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(unverifiedDomains).length > 0) {
|
||||||
|
setExpandedDomains(prev => ({
|
||||||
|
...prev,
|
||||||
|
...unverifiedDomains,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [domains]);
|
||||||
|
|
||||||
const showMessage = (type: 'success' | 'error', message: string) => {
|
const showMessage = (type: 'success' | 'error', message: string) => {
|
||||||
if (type === 'success') {
|
if (type === 'success') {
|
||||||
setSuccessMessage(message);
|
setSuccessMessage(message);
|
||||||
@@ -113,6 +136,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setSelectedDomain(newDomain.id);
|
setSelectedDomain(newDomain.id);
|
||||||
|
// Auto-expand newly added domain
|
||||||
|
setExpandedDomains(prev => ({
|
||||||
|
...prev,
|
||||||
|
[newDomain.id]: true,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutateDomains();
|
await mutateDomains();
|
||||||
@@ -322,20 +350,42 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
|
{Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-3">
|
||||||
<Alert>
|
<button
|
||||||
<AlertDescription>
|
onClick={() =>
|
||||||
<div className="space-y-4">
|
setExpandedDomains(prev => ({
|
||||||
<div>
|
...prev,
|
||||||
<p className="font-medium text-sm mb-1">DNS Configuration Required</p>
|
[domain.id]: !prev[domain.id],
|
||||||
<p className="text-xs text-neutral-600">
|
}))
|
||||||
Add the following DNS records to verify your domain. DNS changes can take up to 48
|
}
|
||||||
hours to propagate.
|
className="flex items-center gap-1.5 text-xs text-neutral-600 hover:text-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 transition-transform ${
|
||||||
|
expandedDomains[domain.id] ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{status.verified ? 'View DNS Records' : 'DNS Configuration Required'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedDomains[domain.id] && (
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
{/* Required DKIM Records for Sending */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Required for Sending</h4>
|
||||||
|
<Badge variant="default" className="text-[10px] px-1.5 py-0">
|
||||||
|
REQUIRED
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!status.verified && (
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Add these DKIM records to verify your domain and send emails. DNS changes can take up
|
||||||
|
to 48 hours to propagate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* DNS Records Table */}
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -401,120 +451,246 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* MX Record */}
|
|
||||||
<tr className="hover:bg-neutral-50/50">
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<code className="text-xs font-medium text-neutral-900">MX</code>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
plunk.{domain.domain}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
10 feedback-smtp.eu-north-1.amazonses.com
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleCopyToken('10 feedback-smtp.eu-north-1.amazonses.com', 1000)
|
|
||||||
}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === '10 feedback-smtp.eu-north-1.amazonses.com-1000' ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* TXT Record (SPF) */}
|
|
||||||
<tr className="hover:bg-neutral-50/50">
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<code className="text-xs font-medium text-neutral-900">TXT</code>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
plunk.{domain.domain}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
"v=spf1 include:amazonses.com ~all"
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
||||||
<div className="text-blue-600 mt-0.5">
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-900">
|
|
||||||
Click the copy icon to copy record values. After adding all records to your DNS
|
|
||||||
provider, use the refresh button above to verify your domain.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
{/* Optional: Custom MAIL FROM Domain */}
|
||||||
|
{config?.aws?.sesRegion && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Custom MAIL FROM Domain</h4>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
OPTIONAL
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
|
||||||
|
handle bounces/complaints.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200">
|
||||||
|
{/* MX Record (Bounce/Complaint Handling) */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">MX</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
plunk.{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
10 feedback-smtp.{config.aws.sesRegion}.amazonses.com
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken(
|
||||||
|
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com`,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken ===
|
||||||
|
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* TXT Record (SPF) */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">TXT</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
plunk.{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
"v=spf1 include:amazonses.com ~all"
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional: Inbound Email */}
|
||||||
|
{config?.aws?.sesRegion && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Inbound Email</h4>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
OPTIONAL
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Configure this MX record to receive emails at your domain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200">
|
||||||
|
{/* Inbound MX Record */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">MX</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(domain.domain, 3002)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `${domain.domain}-3002` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
10 inbound-smtp.{config.aws.sesRegion}.amazonaws.com
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken(
|
||||||
|
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com`,
|
||||||
|
1002,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken ===
|
||||||
|
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
|
||||||
|
<div className="text-blue-600 mt-0.5">
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-900">
|
||||||
|
Click the copy icon to copy record values. After adding all records to your DNS
|
||||||
|
provider, use the refresh button above to verify your domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const wrapEmailWithStyles = (htmlBody: string): string => {
|
|||||||
/* Tailwind Typography (prose) base styles */
|
/* Tailwind Typography (prose) base styles */
|
||||||
.prose {
|
.prose {
|
||||||
color: #374151;
|
color: #374151;
|
||||||
max-width: 65ch;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
.prose [class~="lead"] {
|
.prose [class~="lead"] {
|
||||||
color: #4b5563;
|
color: #4b5563;
|
||||||
@@ -148,8 +148,8 @@ const wrapEmailWithStyles = (htmlBody: string): string => {
|
|||||||
margin-bottom: 0.75em;
|
margin-bottom: 0.75em;
|
||||||
}
|
}
|
||||||
.prose hr {
|
.prose hr {
|
||||||
border-color: #e5e7eb;
|
border: none;
|
||||||
border-top-width: 1px;
|
border-top: 1px solid #e5e7eb;
|
||||||
margin-top: 3em;
|
margin-top: 3em;
|
||||||
margin-bottom: 3em;
|
margin-bottom: 3em;
|
||||||
}
|
}
|
||||||
@@ -352,43 +352,43 @@ const wrapEmailWithStyles = (htmlBody: string): string => {
|
|||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
.prose table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
.prose th, .prose td {
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
.prose th {
|
||||||
background-color: #f3f4f6;
|
background-color: #f3f4f6;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
.prose img {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizable-image-wrapper {
|
.prose .resizable-image-wrapper {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 16px 0;
|
margin: 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizable-image-container {
|
.prose .resizable-image-container {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resizable-image-container img {
|
.prose .resizable-image-container img {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||||
import {BookOpen, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
|
import {BookOpen, Check, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useMemo} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||||
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||||
import {useConfig} from '../lib/hooks/useConfig';
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
@@ -23,6 +24,14 @@ interface QuickStartProps {
|
|||||||
|
|
||||||
// Help resources that always appear
|
// Help resources that always appear
|
||||||
function HelpResources() {
|
function HelpResources() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyEmail = () => {
|
||||||
|
void navigator.clipboard.writeText('[email protected]');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||||
@@ -39,6 +48,39 @@ function HelpResources() {
|
|||||||
Join Discord
|
Join Discord
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<motion.button
|
||||||
|
onClick={copyEmail}
|
||||||
|
whileTap={{scale: 0.97}}
|
||||||
|
className="flex-1 relative flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{copied ? (
|
||||||
|
<motion.span
|
||||||
|
key="copied"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
initial={{opacity: 0, y: 6}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -6}}
|
||||||
|
transition={{duration: 0.15}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
|
<span className="text-green-600">Copied!</span>
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
<motion.span
|
||||||
|
key="idle"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
initial={{opacity: 0, y: 6}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -6}}
|
||||||
|
transition={{duration: 0.15}}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Email support
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
|||||||
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
||||||
{value: 'exists', label: 'Exists'},
|
{value: 'exists', label: 'Exists'},
|
||||||
{value: 'notExists', label: 'Does not exist'},
|
{value: 'notExists', label: 'Does not exist'},
|
||||||
{value: 'within', label: 'Within (time)'},
|
{value: 'within', label: 'Younger than (time)'},
|
||||||
|
{value: 'olderThan', label: 'Older than (time)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
||||||
{value: 'triggered', label: 'Ever occurred'},
|
{value: 'triggered', label: 'Ever occurred'},
|
||||||
{value: 'triggeredWithin', label: 'Occurred within'},
|
{value: 'triggeredWithin', label: 'Occurred within'},
|
||||||
|
{value: 'triggeredOlderThan', label: 'Occurred over (time) ago'},
|
||||||
{value: 'notTriggered', label: 'Never occurred'},
|
{value: 'notTriggered', label: 'Never occurred'},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -172,6 +174,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
|
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
|
||||||
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
|
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator);
|
||||||
|
|
||||||
// Get field type from available fields
|
// Get field type from available fields
|
||||||
const fieldOption = useMemo(
|
const fieldOption = useMemo(
|
||||||
@@ -217,6 +220,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +236,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
const selectedField = availableFields.find(f => f.value === value);
|
const selectedField = availableFields.find(f => f.value === value);
|
||||||
const newFieldType = selectedField?.type || 'string';
|
const newFieldType = selectedField?.type || 'string';
|
||||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
||||||
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered'].includes(
|
||||||
|
filter.operator,
|
||||||
|
);
|
||||||
|
|
||||||
// Determine default operator and value based on new field type
|
// Determine default operator and value based on new field type
|
||||||
let newOperator = filter.operator;
|
let newOperator = filter.operator;
|
||||||
@@ -267,7 +273,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
newUnit = undefined;
|
newUnit = undefined;
|
||||||
|
|
||||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
||||||
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
|
newOperator,
|
||||||
|
);
|
||||||
if (!newOperatorNeedsUnit) {
|
if (!newOperatorNeedsUnit) {
|
||||||
// Convert numeric value back to appropriate type for the field
|
// Convert numeric value back to appropriate type for the field
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
newValue = getDefaultValueForType(newFieldType);
|
||||||
@@ -399,8 +407,12 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
// Check if we're switching between operators that need different value types
|
// Check if we're switching between operators that need different value types
|
||||||
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
||||||
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
||||||
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
|
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
oldOperator,
|
||||||
|
);
|
||||||
|
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
|
newOperator,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,29 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {nodes: layoutedNodes, edges};
|
// Center "Add Step" nodes directly below their parent nodes
|
||||||
|
const adjustedNodes = layoutedNodes.map(node => {
|
||||||
|
if (node.type === 'addStep') {
|
||||||
|
// Find the parent node (the source of the edge connecting to this add node)
|
||||||
|
const parentEdge = edges.find(edge => edge.target === node.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||||
|
if (parentNode) {
|
||||||
|
// Center the add node below the parent node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: parentNode.position.x + nodeWidth / 2 - 32, // 32 is half of the 64px (w-16) width of the add button
|
||||||
|
y: node.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {nodes: adjustedNodes, edges};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Step Node - appears at the end of flow paths
|
// Add Step Node - appears at the end of flow paths
|
||||||
@@ -204,6 +226,22 @@ function CustomNode({
|
|||||||
onMouseLeave={() => setShowActions(false)}
|
onMouseLeave={() => setShowActions(false)}
|
||||||
>
|
>
|
||||||
{/* Action buttons - shown on hover */}
|
{/* Action buttons - shown on hover */}
|
||||||
|
{showActions && data.type === 'TRIGGER' && (
|
||||||
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
|
<Button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
data.onEdit?.();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shadow-md"
|
||||||
|
title="Edit trigger settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showActions && data.type !== 'TRIGGER' && (
|
{showActions && data.type !== 'TRIGGER' && (
|
||||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
<Button
|
<Button
|
||||||
@@ -300,6 +338,14 @@ function CustomNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{data.type === 'TRIGGER' && data.config?.eventName && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
|
<Lightbulb className="h-3 w-3" />
|
||||||
|
<span className="truncate">{data.config.eventName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
@@ -353,11 +399,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
// Define handlers before they are used in useMemo
|
// Define handlers before they are used in useMemo
|
||||||
const handleEditStep = useCallback((stepId: string) => {
|
const handleEditStep = useCallback(
|
||||||
// This will be handled by the parent component
|
(stepId: string) => {
|
||||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
// Check if this is a TRIGGER step
|
||||||
window.dispatchEvent(event);
|
const step = steps.find(s => s.id === stepId);
|
||||||
}, []);
|
|
||||||
|
if (step?.type === 'TRIGGER') {
|
||||||
|
// For TRIGGER steps, open workflow settings instead
|
||||||
|
const event = new CustomEvent('workflow-open-settings');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} else {
|
||||||
|
// For other steps, open step editor
|
||||||
|
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[steps],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteStepClick = useCallback((stepId: string) => {
|
const handleDeleteStepClick = useCallback((stepId: string) => {
|
||||||
setStepToDelete(stepId);
|
setStepToDelete(stepId);
|
||||||
@@ -412,6 +470,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-yes`,
|
id: `${step.id}-add-yes`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: 'Yes',
|
label: 'Yes',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||||
@@ -424,6 +483,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-no`,
|
id: `${step.id}-add-no`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: 'No',
|
label: 'No',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||||
@@ -437,6 +497,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add`,
|
id: `${step.id}-add`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: '',
|
label: '',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id}),
|
onClick: () => setAddStepContext({fromStepId: step.id}),
|
||||||
@@ -510,7 +571,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-yes-edge`,
|
id: `${step.id}-add-yes-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add-yes`,
|
target: `${step.id}-add-yes`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
label: 'Yes',
|
label: 'Yes',
|
||||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||||
@@ -527,7 +588,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-no-edge`,
|
id: `${step.id}-add-no-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add-no`,
|
target: `${step.id}-add-no`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
label: 'No',
|
label: 'No',
|
||||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||||
@@ -544,7 +605,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-edge`,
|
id: `${step.id}-add-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add`,
|
target: `${step.id}-add`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
|
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
|
||||||
@@ -565,6 +626,56 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||||
|
|
||||||
|
// Custom nodes change handler that updates "+" node positions when parent nodes move
|
||||||
|
const handleNodesChange = useCallback(
|
||||||
|
(changes: any[]) => {
|
||||||
|
onNodesChange(changes);
|
||||||
|
|
||||||
|
// Check if any position changes occurred
|
||||||
|
const positionChanges = changes.filter(change => change.type === 'position' && change.dragging);
|
||||||
|
if (positionChanges.length > 0) {
|
||||||
|
setNodes(currentNodes => {
|
||||||
|
const updatedNodes = [...currentNodes];
|
||||||
|
const nodeWidth = 280;
|
||||||
|
|
||||||
|
positionChanges.forEach((change: any) => {
|
||||||
|
const movedNode = updatedNodes.find(n => n.id === change.id);
|
||||||
|
if (movedNode && movedNode.type === 'custom' && change.position) {
|
||||||
|
// Find all "+" nodes connected to this parent node
|
||||||
|
const connectedAddNodes = updatedNodes.filter(node => {
|
||||||
|
if (node.type !== 'addStep') return false;
|
||||||
|
// Check if this add node is connected to the moved node
|
||||||
|
return (
|
||||||
|
node.id === `${movedNode.id}-add` ||
|
||||||
|
node.id === `${movedNode.id}-add-yes` ||
|
||||||
|
node.id === `${movedNode.id}-add-no`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update positions of connected "+" nodes to stay centered below parent
|
||||||
|
connectedAddNodes.forEach(addNode => {
|
||||||
|
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||||
|
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||||
|
const existingNode = updatedNodes[addNodeIndex];
|
||||||
|
updatedNodes[addNodeIndex] = {
|
||||||
|
...existingNode,
|
||||||
|
position: {
|
||||||
|
x: change.position.x + nodeWidth / 2 - 32, // Center below parent
|
||||||
|
y: existingNode.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedNodes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onNodesChange, setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
// Update nodes/edges when layout changes
|
// Update nodes/edges when layout changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodes(layoutedNodes);
|
setNodes(layoutedNodes);
|
||||||
@@ -727,7 +838,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface ConfigResponse {
|
|||||||
ports: {secure: number; submission: number} | null;
|
ports: {secure: number; submission: number} | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
aws: {
|
||||||
|
sesRegion: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+23
-11
@@ -5,6 +5,7 @@ import React, {useEffect} from 'react';
|
|||||||
import {Toaster} from 'sonner';
|
import {Toaster} from 'sonner';
|
||||||
import {SWRConfig} from 'swr';
|
import {SWRConfig} from 'swr';
|
||||||
import {DefaultSeo} from 'next-seo';
|
import {DefaultSeo} from 'next-seo';
|
||||||
|
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
|
||||||
import {Loader} from '@plunk/ui';
|
import {Loader} from '@plunk/ui';
|
||||||
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useProjects} from '../lib/hooks/useProject';
|
import {useProjects} from '../lib/hooks/useProject';
|
||||||
@@ -13,6 +14,7 @@ import {network} from '../lib/network';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||||
|
import Script from 'next/script';
|
||||||
|
|
||||||
// Configure dayjs plugins globally
|
// Configure dayjs plugins globally
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
@@ -103,17 +105,27 @@ function ProjectGuard({children}: {children: React.ReactNode}) {
|
|||||||
*/
|
*/
|
||||||
export default function WithProviders(props: AppProps) {
|
export default function WithProviders(props: AppProps) {
|
||||||
return (
|
return (
|
||||||
<SWRConfig
|
<NuqsAdapter>
|
||||||
value={{
|
<SWRConfig
|
||||||
fetcher: (url: string) => network.fetch('GET', url),
|
value={{
|
||||||
shouldRetryOnError: false,
|
fetcher: (url: string) => network.fetch('GET', url),
|
||||||
}}
|
shouldRetryOnError: false,
|
||||||
>
|
}}
|
||||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
>
|
||||||
<ActiveProjectProvider>
|
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||||
<Root {...props} />
|
|
||||||
</ActiveProjectProvider>
|
<Script
|
||||||
</SWRConfig>
|
defer
|
||||||
|
src="https://analytics.driaug.com/script.js"
|
||||||
|
data-website-id="5880df93-9025-41ae-8e33-7c3da865f764"
|
||||||
|
data-domains="next-app.useplunk.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ActiveProjectProvider>
|
||||||
|
<Root {...props} />
|
||||||
|
</ActiveProjectProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout';
|
|||||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||||
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {useState} from 'react';
|
import {useQueryState, parseAsString} from 'nuqs';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
interface ActivityStats {
|
interface ActivityStats {
|
||||||
@@ -28,8 +28,8 @@ interface ActivityStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL'));
|
||||||
const [dateRange, setDateRange] = useState<string>('30');
|
const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30'));
|
||||||
|
|
||||||
// Fetch activity stats
|
// Fetch activity stats
|
||||||
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
||||||
@@ -119,12 +119,16 @@ export default function ActivityPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">All Activity Types</SelectItem>
|
<SelectItem value="ALL">All Activity Types</SelectItem>
|
||||||
<SelectItem value="event.triggered">Events</SelectItem>
|
<SelectItem value="event.triggered">Events</SelectItem>
|
||||||
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
|
<SelectItem value="email.sent,email.delivered,email.received,email.opened,email.clicked,email.bounced,email.complaint">
|
||||||
Emails
|
Emails
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
||||||
|
<SelectItem value="email.delivered">Emails Delivered</SelectItem>
|
||||||
|
<SelectItem value="email.received">Emails Received</SelectItem>
|
||||||
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
||||||
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
||||||
|
<SelectItem value="email.bounced">Emails Bounced</SelectItem>
|
||||||
|
<SelectItem value="email.complaint">Email Complaints</SelectItem>
|
||||||
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
|
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {WIKI_URI} from '../lib/constants';
|
||||||
|
import type {GetServerSideProps} from 'next';
|
||||||
|
|
||||||
|
export default function Docs() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async () => {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: WIKI_URI,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
|
|||||||
import type {PaginatedResponse} from '@plunk/types';
|
import type {PaginatedResponse} from '@plunk/types';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, Users} from 'lucide-react';
|
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/router';
|
import {useRouter} from 'next/router';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -22,8 +22,12 @@ import useSWR from 'swr';
|
|||||||
import type {FilterCondition} from '@plunk/types';
|
import type {FilterCondition} from '@plunk/types';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
type SegmentWithType = Segment & {type: SegmentType};
|
||||||
|
|
||||||
// Count total filters in a condition (recursive)
|
// Count total filters in a condition (recursive)
|
||||||
function countFilters(condition: FilterCondition): number {
|
function countFilters(condition: FilterCondition): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -40,11 +44,13 @@ export default function SegmentDetailPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const {id} = router.query;
|
const {id} = router.query;
|
||||||
|
|
||||||
const {data: segment, mutate, isLoading} = useSWR<Segment>(id ? `/segments/${id}` : null);
|
const {data: segment, mutate, isLoading} = useSWR<SegmentWithType>(id ? `/segments/${id}` : null);
|
||||||
const [contactsPage, setContactsPage] = useState(1);
|
const [contactsPage, setContactsPage] = useState(1);
|
||||||
const {data: contactsData, isLoading: isLoadingContacts} = useSWR<PaginatedResponse<Contact>>(
|
const {
|
||||||
id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null,
|
data: contactsData,
|
||||||
);
|
isLoading: isLoadingContacts,
|
||||||
|
mutate: mutateContacts,
|
||||||
|
} = useSWR<PaginatedResponse<Contact>>(id ? `/segments/${id}/contacts?page=${contactsPage}&pageSize=10` : null);
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
@@ -57,7 +63,12 @@ export default function SegmentDetailPage() {
|
|||||||
const [isComputing, setIsComputing] = useState(false);
|
const [isComputing, setIsComputing] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Static segment member management
|
||||||
|
const [pickedEmails, setPickedEmails] = useState<string[]>([]);
|
||||||
|
const [isAddingMembers, setIsAddingMembers] = useState(false);
|
||||||
|
const [removingEmail, setRemovingEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (segment) {
|
if (segment) {
|
||||||
setName(segment.name);
|
setName(segment.name);
|
||||||
setDescription(segment.description || '');
|
setDescription(segment.description || '');
|
||||||
@@ -79,7 +90,7 @@ export default function SegmentDetailPage() {
|
|||||||
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
await network.fetch<Segment, typeof SegmentSchemas.update>('PATCH', `/segments/${id}`, {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
...(segment?.type !== 'STATIC' && {condition}),
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
toast.success('Segment updated successfully');
|
toast.success('Segment updated successfully');
|
||||||
@@ -112,6 +123,47 @@ export default function SegmentDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddMembers = async () => {
|
||||||
|
if (pickedEmails.length === 0) {
|
||||||
|
toast.error('Select at least one contact');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAddingMembers(true);
|
||||||
|
try {
|
||||||
|
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||||
|
'POST',
|
||||||
|
`/segments/${id}/members`,
|
||||||
|
{emails: pickedEmails},
|
||||||
|
);
|
||||||
|
|
||||||
|
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
|
||||||
|
setPickedEmails([]);
|
||||||
|
void mutate();
|
||||||
|
void mutateContacts();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to add contacts');
|
||||||
|
} finally {
|
||||||
|
setIsAddingMembers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveMember = async (email: string) => {
|
||||||
|
setRemovingEmail(email);
|
||||||
|
try {
|
||||||
|
await network.fetch<{removed: number}, typeof SegmentSchemas.members>('DELETE', `/segments/${id}/members`, {
|
||||||
|
emails: [email],
|
||||||
|
});
|
||||||
|
toast.success(`Removed ${email} from segment`);
|
||||||
|
void mutate();
|
||||||
|
void mutateContacts();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to remove contact');
|
||||||
|
} finally {
|
||||||
|
setRemovingEmail(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await network.fetch('DELETE', `/segments/${id}`);
|
await network.fetch('DELETE', `/segments/${id}`);
|
||||||
@@ -166,6 +218,8 @@ export default function SegmentDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStatic = segment.type === 'STATIC';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -178,8 +232,17 @@ export default function SegmentDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-neutral-500 mt-1">{segment.description}</p>
|
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
isStatic ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isStatic ? 'Static' : 'Dynamic'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{segment.description && <p className="text-neutral-500 mt-1">{segment.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)}>
|
||||||
@@ -244,12 +307,14 @@ export default function SegmentDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder (DYNAMIC only) */}
|
||||||
<Card>
|
{!isStatic && (
|
||||||
<CardContent className="pt-6">
|
<Card>
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<CardContent className="pt-6">
|
||||||
</CardContent>
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
@@ -260,15 +325,44 @@ export default function SegmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Static member management */}
|
||||||
|
{isStatic && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add Members</CardTitle>
|
||||||
|
<CardDescription>Search and select contacts to add to this segment</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<ContactPicker
|
||||||
|
selected={pickedEmails}
|
||||||
|
onChange={setPickedEmails}
|
||||||
|
existing={contactsData?.data.map(c => c.email) ?? []}
|
||||||
|
placeholder="Search contacts to add..."
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddMembers}
|
||||||
|
disabled={isAddingMembers || pickedEmails.length === 0}
|
||||||
|
>
|
||||||
|
{isAddingMembers
|
||||||
|
? 'Adding...'
|
||||||
|
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contacts */}
|
{/* Contacts */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Matching Contacts</CardTitle>
|
<CardTitle>{isStatic ? 'Members' : 'Matching Contacts'}</CardTitle>
|
||||||
<CardDescription>Contacts that match this segment's filters</CardDescription>
|
<CardDescription>
|
||||||
|
{isStatic ? 'Contacts in this static segment' : "Contacts that match this segment's filters"}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{trackMembership && (
|
{!isStatic && trackMembership && (
|
||||||
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
<Button variant="outline" size="sm" onClick={handleComputeMembership} disabled={isComputing}>
|
||||||
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isComputing ? 'animate-spin' : ''}`} />
|
||||||
{isComputing ? 'Computing...' : 'Recompute'}
|
{isComputing ? 'Computing...' : 'Recompute'}
|
||||||
@@ -284,7 +378,9 @@ export default function SegmentDetailPage() {
|
|||||||
) : contactsData?.data.length === 0 ? (
|
) : contactsData?.data.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||||
<p className="text-neutral-500">No contacts match this segment</p>
|
<p className="text-neutral-500">
|
||||||
|
{isStatic ? 'No members in this segment yet' : 'No contacts match this segment'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -299,11 +395,24 @@ export default function SegmentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium">{contact.email}</span>
|
<span className="text-sm font-medium">{contact.email}</span>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/contacts/${contact.id}`}>
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="ghost" size="sm">
|
<Link href={`/contacts/${contact.id}`}>
|
||||||
View
|
<Button variant="ghost" size="sm">
|
||||||
</Button>
|
View
|
||||||
</Link>
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{isStatic && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRemoveMember(contact.email)}
|
||||||
|
disabled={removingEmail === contact.email}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<UserMinus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -355,24 +464,28 @@ export default function SegmentDetailPage() {
|
|||||||
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
<span className="text-2xl font-bold text-neutral-900">{segment.memberCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
{!isStatic && (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
<Filter className="h-4 w-4 text-neutral-500" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-600">Filters</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Filter className="h-4 w-4 text-neutral-500" />
|
||||||
<span className="text-lg font-semibold text-neutral-900">
|
<span className="text-sm text-neutral-600">Filters</span>
|
||||||
{countFilters(segment.condition as unknown as FilterCondition)}
|
</div>
|
||||||
</span>
|
<span className="text-lg font-semibold text-neutral-900">
|
||||||
</div>
|
{countFilters(segment.condition as unknown as FilterCondition)}
|
||||||
<div className="flex items-center justify-between">
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<Filter className="h-4 w-4 text-neutral-500" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-neutral-600">Groups</span>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Filter className="h-4 w-4 text-neutral-500" />
|
||||||
<span className="text-lg font-semibold text-neutral-900">
|
<span className="text-sm text-neutral-600">Groups</span>
|
||||||
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
</div>
|
||||||
</span>
|
<span className="text-lg font-semibold text-neutral-900">
|
||||||
</div>
|
{(segment.condition as unknown as FilterCondition)?.groups?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,18 @@ export default function SegmentsPage() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle className="text-lg">{segment.name}</CardTitle>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
(segment as unknown as {type: string}).type === 'STATIC'
|
||||||
|
? 'bg-purple-100 text-purple-700'
|
||||||
|
: 'bg-blue-100 text-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{segment.description && (
|
{segment.description && (
|
||||||
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
<CardDescription className="mt-1">{segment.description}</CardDescription>
|
||||||
)}
|
)}
|
||||||
@@ -170,7 +181,9 @@ export default function SegmentsPage() {
|
|||||||
<span className="text-sm text-neutral-600">Filters</span>
|
<span className="text-sm text-neutral-600">Filters</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium text-neutral-900">
|
<span className="text-sm font-medium text-neutral-900">
|
||||||
{countFiltersInCondition(segment.condition)}
|
{(segment as unknown as {type: string}).type === 'STATIC'
|
||||||
|
? '—'
|
||||||
|
: countFiltersInCondition(segment.condition)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input
|
|||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
|
||||||
|
import {ContactPicker} from '../../components/ContactPicker';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {ArrowLeft, Save} from 'lucide-react';
|
import {ArrowLeft, Save} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@@ -12,10 +13,13 @@ import type {FilterCondition} from '@plunk/types';
|
|||||||
import type {Segment} from '@plunk/db';
|
import type {Segment} from '@plunk/db';
|
||||||
import {SegmentSchemas} from '@plunk/shared';
|
import {SegmentSchemas} from '@plunk/shared';
|
||||||
|
|
||||||
|
type SegmentType = 'DYNAMIC' | 'STATIC';
|
||||||
|
|
||||||
export default function NewSegmentPage() {
|
export default function NewSegmentPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [segmentType, setSegmentType] = useState<SegmentType>('DYNAMIC');
|
||||||
const [trackMembership, setTrackMembership] = useState(false);
|
const [trackMembership, setTrackMembership] = useState(false);
|
||||||
const [condition, setCondition] = useState<FilterCondition>({
|
const [condition, setCondition] = useState<FilterCondition>({
|
||||||
logic: 'AND',
|
logic: 'AND',
|
||||||
@@ -25,6 +29,7 @@ export default function NewSegmentPage() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const [selectedContacts, setSelectedContacts] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -32,13 +37,33 @@ export default function NewSegmentPage() {
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
const segment = await network.fetch<Segment, typeof SegmentSchemas.create>('POST', '/segments', {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
condition,
|
type: segmentType,
|
||||||
|
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||||
trackMembership,
|
trackMembership,
|
||||||
});
|
});
|
||||||
toast.success('Segment created successfully');
|
|
||||||
|
// For static segments with pre-selected contacts, add them now
|
||||||
|
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
|
||||||
|
try {
|
||||||
|
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
|
||||||
|
'POST',
|
||||||
|
`/segments/${segment.id}/members`,
|
||||||
|
{emails: selectedContacts},
|
||||||
|
);
|
||||||
|
toast.success(
|
||||||
|
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Segment was created; just warn about members
|
||||||
|
toast.warning('Segment created, but some contacts could not be added');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success('Segment created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
void router.push('/segments');
|
void router.push('/segments');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
toast.error(error instanceof Error ? error.message : 'Failed to create segment');
|
||||||
@@ -61,10 +86,40 @@ export default function NewSegmentPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
|
||||||
<p className="text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
|
<p className="text-neutral-500 mt-1">
|
||||||
|
{segmentType === 'DYNAMIC'
|
||||||
|
? 'Build complex audience filters with AND/OR logic'
|
||||||
|
: 'Manually curate a list of contacts'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Type Toggle */}
|
||||||
|
<div className="flex gap-2 p-1 bg-neutral-100 rounded-lg w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSegmentType('DYNAMIC')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
segmentType === 'DYNAMIC'
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Dynamic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSegmentType('STATIC')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||||
|
segmentType === 'STATIC'
|
||||||
|
? 'bg-white text-neutral-900 shadow-sm'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Static
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Card>
|
<Card>
|
||||||
@@ -118,12 +173,30 @@ export default function NewSegmentPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Filter Builder */}
|
{/* Filter Builder or Contact Picker */}
|
||||||
<Card>
|
{segmentType === 'DYNAMIC' ? (
|
||||||
<CardContent className="pt-6">
|
<Card>
|
||||||
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
<CardContent className="pt-6">
|
||||||
</CardContent>
|
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
|
||||||
</Card>
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Initial Members</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Optionally add contacts now — you can always add or remove members later
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ContactPicker
|
||||||
|
selected={selectedContacts}
|
||||||
|
onChange={setSelectedContacts}
|
||||||
|
placeholder="Search and select contacts..."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export default function TemplateEditorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!template) {
|
if (!template || Object.keys(editedTemplate).length === 0) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
|||||||
+1226
-730
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import './global.css';
|
import './global.css';
|
||||||
import {DocsLayout} from 'fumadocs-ui/layouts/docs';
|
import {DocsLayout} from 'fumadocs-ui/layouts/docs';
|
||||||
import {RootProvider} from 'fumadocs-ui/provider/next';
|
import {RootProvider} from 'fumadocs-ui/provider/next';
|
||||||
|
import Script from 'next/script';
|
||||||
import type {ReactNode} from 'react';
|
import type {ReactNode} from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -64,6 +65,12 @@ export default function Layout({children}: {children: ReactNode}) {
|
|||||||
<meta name="theme-color" content="#ffffff" />
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src="https://analytics.driaug.com/script.js"
|
||||||
|
data-website-id="ba0b7094-e693-492e-902a-c62aab868715"
|
||||||
|
data-domains="docs.useplunk.com"
|
||||||
|
/>
|
||||||
<body className="flex flex-col min-h-screen antialiased text-neutral-800" suppressHydrationWarning>
|
<body className="flex flex-col min-h-screen antialiased text-neutral-800" suppressHydrationWarning>
|
||||||
<RootProvider
|
<RootProvider
|
||||||
theme={{
|
theme={{
|
||||||
|
|||||||
@@ -244,11 +244,13 @@ For a complete list of error codes and troubleshooting guidance, see the [Error
|
|||||||
### Segments
|
### Segments
|
||||||
|
|
||||||
**GET /segments** — List all segments
|
**GET /segments** — List all segments
|
||||||
**POST /segments** — Create new segment
|
**POST /segments** — Create new segment (Dynamic or Static)
|
||||||
**GET /segments/:id** — Get segment details
|
**GET /segments/:id** — Get segment details
|
||||||
**PATCH /segments/:id** — Update segment
|
**PATCH /segments/:id** — Update segment
|
||||||
**DELETE /segments/:id** — Delete segment
|
**DELETE /segments/:id** — Delete segment
|
||||||
**GET /segments/:id/contacts** — List segment members
|
**GET /segments/:id/contacts** — List segment members
|
||||||
|
**POST /segments/:id/members** — Add contacts to a static segment (by email)
|
||||||
|
**DELETE /segments/:id/members** — Remove contacts from a static segment (by email)
|
||||||
|
|
||||||
### Workflows
|
### Workflows
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ After upgrading from the free tier, you will be charged per email sent at $0.001
|
|||||||
You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings.
|
You are able to monitor your email usage and set billing limits per category in the billing tab of the project settings.
|
||||||
|
|
||||||
## Special considerations
|
## Special considerations
|
||||||
- Emails that contain an attachment will incur double the cost (e.g. 1 email with attachment = 2 emails for billing purposes)
|
- Emails that contain an attachment will incur double the cost (e.g. 1 email with attachment = 2 emails for billing purposes)
|
||||||
|
- Inbound emails count towards the email limit at the same rate as outbound emails (e.g. 1 inbound email = 1 email for billing purposes)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user