Compare commits

...
144 Commits
Author SHA1 Message Date
Dries Augustyns b1047590e6 Merge pull request #250 from useplunk/release-please--branches--next--components--plunk 2026-01-09 08:41:30 +01:00
github-actions[bot] e3cf0fe0c1 chore(next): release 0.4.0 2026-01-08 13:19:17 +00:00
Dries Augustyns 940c8938f1 fix: Refactor CORS handling to allow unrestricted access for public API endpoints 2026-01-08 14:18:49 +01:00
Dries Augustyns e75e07f73f fix: Add better validation for sender email 2026-01-08 09:04:50 +01:00
Dries Augustyns 940a4d225b docs: Add more details about personalisation 2026-01-07 17:53:08 +01:00
Dries Augustyns 7386441e61 feat: Add id as reserved field in templates, campaigns and workflows 2026-01-07 17:49:36 +01:00
Dries Augustyns 718251c67c fix: Enhance CORS handling to allow requests with rejection logging 2026-01-07 13:11:54 +01:00
Dries Augustyns b6c5471d27 fix: Update template fetching to use Template type and simplify body access 2026-01-06 07:46:28 +01:00
Dries Augustyns fa22b8220a fix: Update templates data fetching to use PaginatedResponse type 2026-01-06 07:35:12 +01:00
Dries Augustyns a5c575444b fix: Catch unknown content-type headers 2026-01-04 17:27:27 +01:00
Dries Augustyns 4dce71a1fe fix: Reentry into segment not working after exit 2026-01-03 11:40:27 +01:00
Dries Augustyns ee00eb3481 fix: Update sentCount on campaign sent for correct overview stats 2026-01-03 11:18:44 +01:00
Dries Augustyns 5d44b1606d chore: Throttle notifications for billing limits and security warnings 2026-01-03 10:18:28 +01:00
Dries Augustyns e732c76490 feat: Add forwarding domains as verification check 2026-01-03 09:54:17 +01:00
Dries Augustyns fb02051538 feat: Add email verification on signup 2026-01-02 15:41:50 +01:00
Dries Augustyns 6f3853de59 test: Migrate test to use new pagination format 2026-01-02 10:50:53 +01:00
Dries Augustyns 8790c45edc fix: Migrate over to new pagination format in dashboard 2026-01-02 10:40:39 +01:00
Dries Augustyns 49824aff93 fix: Copy types build files 2026-01-02 10:20:07 +01:00
Dries Augustyns 492beb095f fix: Check email volume for 7-day window 2026-01-02 08:56:32 +01:00
Dries Augustyns d11c4af4d1 Merge pull request #251 from useplunk/dependabot/npm_and_yarn/qs-6.14.1 2026-01-01 14:28:38 +01:00
Dries Augustyns 82a7c8490e Merge branch 'next' into dependabot/npm_and_yarn/qs-6.14.1 2026-01-01 14:28:29 +01:00
Dries Augustyns 457c829b2d feat: Add cooldown to resend verification email 2026-01-01 14:26:26 +01:00
Dries Augustyns da7f3e5718 chore: Remove comments 2026-01-01 14:08:08 +01:00
Dries Augustyns 76786b2eae chore: Include types as dep for shared 2026-01-01 11:02:31 +01:00
Dries Augustyns 3a299cae98 chore: Include types as dep for shared 2026-01-01 10:58:39 +01:00
Dries Augustyns dd4737cfe9 chore: Include types as dep for shared 2026-01-01 10:56:59 +01:00
dependabot[bot] 02416654b6 chore(deps): bump qs from 6.14.0 to 6.14.1
Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-01 09:55:20 +00:00
Dries Augustyns 85c992a9f9 types: Abstract inline interfaces to @plunk/types 2026-01-01 10:52:43 +01:00
Dries Augustyns 38da58e5e9 types: Abstract inline interfaces to @plunk/types 2026-01-01 09:09:03 +01:00
Dries Augustyns 519b131792 chore: Rely more on services in auth middleware 2025-12-31 16:51:44 +01:00
Dries Augustyns be2eb57369 chore: Consolidate membership checks in single service 2025-12-31 16:20:17 +01:00
Dries Augustyns 19554e6e8f feat: Add platform emails for domain verification and expiration 2025-12-31 15:56:40 +01:00
Dries Augustyns 68b7e7c082 tests: fix date calculation for previous month in billing limit tests 2025-12-31 15:20:28 +01:00
Dries Augustyns 7648f6e1eb chore: Replace references to deprecated services 2025-12-31 15:08:26 +01:00
Dries Augustyns 289c633189 chore: Release please push 2025-12-30 13:29:16 +01:00
Dries Augustyns 857bb8ac9e chore: Default variables on Plunk Hosted 2025-12-30 13:28:04 +01:00
Dries Augustyns cf6ad0b8e0 Merge pull request #241 from useplunk/release-please--branches--next--components--plunk 2025-12-29 21:23:49 +01:00
Dries Augustyns 782dc5754d chore: Add Plunk API key to entrypoint 2025-12-29 20:09:35 +01:00
Dries Augustyns cf88680a13 chore: Add Plunk API key to entrypoint 2025-12-29 20:01:18 +01:00
Dries Augustyns 6293259225 chore: Add free tools on marketing pages 2025-12-29 19:22:35 +01:00
github-actions[bot] a8678b8842 chore(next): release 0.3.0 2025-12-29 14:52:10 +00:00
Dries Augustyns 5993b842a0 fix: Add styling for visual editor emails in preview 2025-12-29 15:51:39 +01:00
Dries Augustyns 78cef418a1 chore: Copy over manifest files 2025-12-29 15:42:18 +01:00
Dries Augustyns d56ba0cf9b chore: Resolve linting warnings 2025-12-29 15:29:29 +01:00
Dries Augustyns 72dffe12e5 feat: Email preview in contact and activity feed 2025-12-29 15:26:53 +01:00
Dries Augustyns 2d353ef839 chore: Do not dockerignore scripts 2025-12-29 14:27:04 +01:00
Dries Augustyns f26ef53c8c chore: Improve startup speed of containers 2025-12-29 14:23:21 +01:00
Dries Augustyns 98b95b2ab2 chore: Add additional comparison pages 2025-12-29 11:53:08 +01:00
Dries Augustyns bc8611a662 feat: Add additional banner and information about security metrics 2025-12-28 14:00:15 +01:00
Dries Augustyns 2ddfceca36 docs: Improve docs with core-concept and guides 2025-12-28 10:46:46 +01:00
Dries Augustyns 862babb8f5 fix: Set auth type before disable check 2025-12-27 14:40:28 +01:00
Dries Augustyns 61cea95697 fix: Correctly reserve fields from being set on contact 2025-12-25 17:59:52 +01:00
Dries Augustyns 1fc1e23fce docs: Add locale overwrite to project documentation 2025-12-25 17:53:58 +01:00
Dries Augustyns 76155232a3 feat: Ability to overwrite locale on contact level with locale key on data 2025-12-25 17:37:25 +01:00
Dries Augustyns fa9e9de032 Merge pull request #249 from manjhss/feat/i18n-german-locale 2025-12-25 16:59:43 +01:00
Surendra Manjhi a6bd2e7dba feat(i18n): add German translations and update supported languages 2025-12-25 16:20:55 +05:30
Dries Augustyns e58dc1d056 Merge pull request #248 from manish-raana/feat/add-hindi-translations 2025-12-25 08:43:29 +01:00
Manish Rana ddc14ae853 feat(i18n): add Hindi translations for contact-facing pages
- Add hi.json locale file with Hindi translations
- Add Hindi language to SUPPORTED_LANGUAGES
- Import Hindi translations in i18n index

Closes #246
2025-12-24 23:59:35 +05:30
Dries Augustyns 0d54b1a631 docs: Add plus address check to /v1/verify 2025-12-24 12:18:53 +01:00
Dries Augustyns afc405ec02 feat: Add plus address check to /v1/verify 2025-12-24 12:14:39 +01:00
Dries Augustyns 6a9f6aa65a feat: Add email verification endpoint at /v1/verify 2025-12-24 09:54:50 +01:00
Dries Augustyns 8f725c7c84 fix: Properly tag events in SegmentFilterBuilder.tsx 2025-12-23 10:52:03 +01:00
Dries Augustyns 97ab0a2c2c fix: Date filtering not working properly for custom contact data 2025-12-22 21:39:15 +01:00
Dries Augustyns ddd58c20aa Merge pull request #247 from benoiteveillard/feat/i18n-french-locale 2025-12-22 10:33:29 +01:00
Benoît Eveillard 489c465599 i18n: add French (fr) locale 2025-12-22 09:46:28 +01:00
Dries Augustyns 451dd0327f fix: Import no longer case-sensitive about email column 2025-12-21 16:43:00 +01:00
Dries Augustyns e1f826357d feat: Add project-scoped language for unsubscribe footer and contact-facing pages 2025-12-21 13:09:21 +01:00
Dries Augustyns 8a136dde55 feat: Allow to pick currency when starting subscription 2025-12-21 11:53:32 +01:00
Dries Augustyns cc9b0f8a73 chore: Remove incorrect information from SEO schemas 2025-12-21 11:00:47 +01:00
Dries Augustyns 3cada63f15 chore: Correct linting errors in sitemap 2025-12-21 09:12:29 +01:00
Dries Augustyns 7fdcb22515 chore: Introduce sitemap for wiki 2025-12-21 09:07:14 +01:00
Dries Augustyns d7b5d3f60e fix: Pass through email verification if auth type is apiKey 2025-12-20 21:40:26 +01:00
Dries Augustyns 24f82b1be5 chore: Improve dropdown on mobile Navbar.tsx 2025-12-20 21:05:15 +01:00
Dries Augustyns 708dc81b88 chore: Make guide pages responsive on small screens 2025-12-20 21:03:17 +01:00
Dries Augustyns 35f5275d88 fix: Redirect verification link to dashboard instead of landing 2025-12-20 20:41:26 +01:00
Dries Augustyns 9567144390 fix: Do not check verification if platform emails are not enabled 2025-12-20 20:12:43 +01:00
Dries Augustyns 1a5607f278 feat: Add email verification and password reset 2025-12-20 20:06:25 +01:00
Dries Augustyns 7e25c148cc chore: Add index pages for /vs 2025-12-20 11:47:12 +01:00
Dries Augustyns 122713c39a chore: Resolve sitemap config by turning it into .cjs 2025-12-20 11:27:08 +01:00
Dries Augustyns 070fd812b2 chore: Ignore linting error due to useLayoutEffect 2025-12-20 11:18:46 +01:00
Dries Augustyns 4156cd436d chore: Add guides and comparison pages to landing site 2025-12-20 11:05:45 +01:00
Dries Augustyns 996852a506 chore: Additional pages 2025-12-19 19:20:30 +01:00
Dries Augustyns 2fb588357a chore: Add free tier to landing page 2025-12-19 17:25:28 +01:00
Dries Augustyns c3a276cde3 chore: Add additional links and shields 2025-12-19 14:20:10 +01:00
Dries Augustyns 661bf2a9e7 style: Remove shadow from input element for consistency 2025-12-19 13:40:52 +01:00
Dries Augustyns 007a908e83 fix: Persistence of subscription state for existing contacts 2025-12-18 19:43:59 +01:00
Dries Augustyns 23b4ec992d Merge pull request #245 from hemanth5544/next 2025-12-18 16:13:00 +01:00
Dries Augustyns 726f66762b feat: Add bulk actions to contact overview 2025-12-18 16:12:42 +01:00
Hemanth Rachapalli 3b77ca5570 style: Remove active line background color in HtmlEditor 2025-12-18 18:44:16 +05:30
Dries Augustyns 8c0304273c fix: Variable substitution in transactional emails 2025-12-17 20:00:27 +01:00
Dries Augustyns dc9b88dedb docs: Update openapi.json to match actual API outputs 2025-12-17 08:01:17 +01:00
Dries Augustyns 409d1b83eb Merge pull request #231 from useplunk/release-please--branches--next--components--plunk 2025-12-16 16:24:37 +01:00
github-actions[bot] c3bd5d1d5f chore(next): release 0.2.0 2025-12-16 12:51:03 +00:00
Dries Augustyns 3336fa1e38 fix: Overflow of inputs in email editor 2025-12-16 13:50:32 +01:00
Dries Augustyns 769e3748a0 fix: Increase z-index of color picker 2025-12-16 13:19:52 +01:00
Dries Augustyns 91eb0f3a69 fix: Better highlight warnings in SecurityWarningBanner.tsx 2025-12-16 12:00:33 +01:00
Dries Augustyns 863e784e1c fix: Add additional checks for disabled projects 2025-12-16 11:52:53 +01:00
Dries Augustyns 780741e37f fix: Add additional checks for disabled projects 2025-12-15 07:41:21 +01:00
Dries Augustyns e50c33ae4b fix: Clear notification cache keys when changing billing limits 2025-12-13 10:28:11 +01:00
Dries Augustyns f34052ed73 fix: Prevent manual tracking of internal events that are automatically tracked 2025-12-12 20:30:49 +01:00
Dries Augustyns a555a12736 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:11 +01:00
Dries Augustyns f37bfccbc2 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:02 +01:00
Dries Augustyns db1496e525 chore: remove module config 2025-12-12 12:44:04 +01:00
Dries Augustyns 443c6271f1 chore: Add email package to vitest config 2025-12-12 12:38:58 +01:00
Dries Augustyns 9cf5222c8c chore: Update turbo.json and example docker-compose to include Plunk .env variables 2025-12-12 12:31:30 +01:00
Dries Augustyns 2485d2ff1d feat: Added platform emails for billing limits and disabled projects 2025-12-12 12:28:53 +01:00
Dries Augustyns cb40669385 chore: Remove accidental package-lock.json 2025-12-12 08:16:25 +01:00
Dries Augustyns d9e7f9f5cf Merge pull request #238 from Nithur-M/next 2025-12-12 08:14:25 +01:00
Dries Augustyns dafa90f0dd chore: Also updated react and react-dom versions 2025-12-12 08:05:10 +01:00
nithur-m 9737c2b67d update to next 16.0.10 2025-12-12 09:04:57 +05:30
Dries Augustyns 1cd89d1375 fix: add additional indexes on event model 2025-12-11 19:09:54 +01:00
Dries Augustyns 11ef47b576 fix: Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages 2025-12-11 18:41:37 +01:00
Dries Augustyns fbd303801f feat: Add security center and warning for exceeding bounce/complaint rates 2025-12-11 18:34:30 +01:00
Dries Augustyns 0ecd82d62e Merge pull request #234 from Nithur-M/next 2025-12-11 18:24:12 +01:00
nithur-m 1452cbd591 format fulldate for campaign sent time 2025-12-11 20:03:01 +05:30
Dries Augustyns 07f875c18c fix: display email progress instead of scheduling progress for campaigns 2025-12-11 14:36:26 +01:00
Dries Augustyns 5e4adb71ad fix: custom relative time to shorten strings for better UI fit 2025-12-11 11:13:39 +01:00
Dries Augustyns 1019ba0d82 feat: Improved createdAt and updatedAt visualisation 2025-12-11 10:57:56 +01:00
Dries Augustyns 6b25bbe2f8 feat: ability to create new campaigns based on templates or previous campaigns 2025-12-10 21:04:51 +01:00
Dries Augustyns b9758149f5 chore: update description in subscription card 2025-12-10 20:30:27 +01:00
Dries Augustyns d438abf1a5 Merge branch 'next' of https://github.com/useplunk/plunk into next 2025-12-10 19:53:26 +01:00
Dries Augustyns 9375c598ee chore: add indicator about campaign recipients recalculation 2025-12-10 19:52:09 +01:00
Dries Augustyns 8f636cc5b9 Merge pull request #232 from Nithur-M/next 2025-12-10 16:07:03 +01:00
Dries Augustyns 1430f31e2d chore: replace standard logging with signale 2025-12-10 14:21:42 +01:00
Dries Augustyns 3225c5005b feat: Automatically detect rate limit from AWS with ability to override in .env 2025-12-10 14:03:30 +01:00
nithur-m f36b213f0a show text left on projects dropdown 2025-12-10 17:03:49 +05:30
Dries Augustyns cb15fad7da test: properly mock Stripe in CampaignService.test.ts 2025-12-09 19:05:22 +01:00
Dries Augustyns a3bbb1ed64 fix: prevent scheduling of campaign if billing limit reached 2025-12-09 18:50:05 +01:00
Dries Augustyns 04275e3cf5 fix: show correct default value for placeholder 2025-12-09 15:57:54 +01:00
Dries Augustyns badb035585 fix: ability to clear reply-to and from name from templates and campaigns 2025-12-09 15:47:27 +01:00
Dries Augustyns c12d0c0898 test: Correctly mock STRIPE_ENABLED 2025-12-09 15:23:48 +01:00
Dries Augustyns 6e9f11f6a7 test: Correctly mock STRIPE_ENABLED 2025-12-09 14:52:40 +01:00
Dries Augustyns 1713b2398d fix: Only check free tier limits if billing is enabled 2025-12-09 14:26:59 +01:00
Dries Augustyns 68f99798f8 fix: Verify if sending without tracking is possible in SESService 2025-12-09 14:10:47 +01:00
Dries Augustyns 5a0a41c6bb fix: Only fetch project members if project Id is defined 2025-12-09 13:24:54 +01:00
Dries Augustyns 129a496206 chore: linting error 2025-12-09 11:24:13 +01:00
Dries Augustyns d0191be31d fix: Correctly show recipient count during creation and edit 2025-12-09 11:08:53 +01:00
Dries Augustyns 85b9d7afe7 fix: Allow changing audience type after creation of campaign 2025-12-09 10:40:42 +01:00
Dries Augustyns ff5c79cfcf fix: Update recipient count on create/update of campaign 2025-12-09 10:27:56 +01:00
Dries Augustyns 672f1e6657 feat: Add improved html editor using CodeMirror 2025-12-09 10:15:33 +01:00
Dries Augustyns 433e796c04 fix: Hide upsell banner if billing is not configured 2025-12-09 08:45:53 +01:00
Dries Augustyns 77089fcdd6 style: Hide labels of tabs on smaller screens 2025-12-08 21:48:09 +01:00
Dries Augustyns a372e55b78 style: Added placeholder to EmailEditor.tsx 2025-12-08 17:45:08 +01:00
Dries Augustyns 8ec0f50f68 chore: Set version of local database to postgres-16 to align with production template 2025-12-08 17:40:24 +01:00
319 changed files with 29763 additions and 6768 deletions
+1
View File
@@ -57,6 +57,7 @@ vitest.config.ts
Dockerfile*
docker
!docker/nginx
!docker/*.sh
.dockerignore
# Misc
+33
View File
@@ -18,6 +18,20 @@ DASHBOARD_DOMAIN=app.example.com
LANDING_DOMAIN=www.example.com
WIKI_DOMAIN=docs.example.com
# For local development: URIs for running services locally
# These are used by the applications at runtime
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# Use placeholder URLs that will be replaced at Docker container runtime
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
# This affects how application URIs are auto-generated from domain names
USE_HTTPS=false
@@ -112,6 +126,25 @@ SMTP_DOMAIN=smtp.example.com
# Maximum recipients per email (default: 5)
# MAX_RECIPIENTS=5
# ========================================
# OPTIONAL: Platform emails
# ========================================
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
# These emails include system notifications, for example when your project hits billing limits
# PLUNK_API_KEY=
# PLUNK_FROM_ADDRESS=
# ========================================
# OPTIONAL: Security Settings
# ========================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# When enabled (default), projects exceeding security limits will be automatically suspended
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
# Recommended for self-hosters: false (manage project status manually)
# Default: true
# AUTO_PROJECT_DISABLE=false
# ========================================
# ADVANCED (rarely needed)
# ========================================
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Run linter
run: yarn lint
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.1.1"
".": "0.4.0"
}
+110
View File
@@ -1,5 +1,115 @@
# Changelog
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
### Features
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
### Bug Fixes
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
### Documentation
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
### Features
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
### Bug Fixes
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
### Documentation
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
### Features
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
### Bug Fixes
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
+12 -3
View File
@@ -67,9 +67,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
- HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
- **wiki**: Next.js app - Documentation site (docs.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)
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
### Background Job Architecture
@@ -108,6 +108,7 @@ between groups.
- Consistent type imports preferred: `import type { ... }`
- Unused vars allowed with `_` prefix
- Strict type checking enabled across all packages
- Try to avoid inline types in favor of shared types in `@plunk/types`
### Component Structure
@@ -145,6 +146,8 @@ Required for builds and deployment (see turbo.json and .env.example):
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
`STRIPE_METER_EVENT_NAME`
- 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
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
**Important Notes:**
@@ -154,3 +157,9 @@ Required for builds and deployment (see turbo.json and .env.example):
runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access
## Plugins
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.
- 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.
+3 -3
View File
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
- **api**: Express.js API server with background worker process (BullMQ)
- **web**: Next.js dashboard application (app.useplunk.com)
- **landing**: Next.js marketing site (www.useplunk.com)
- **wiki**: Next.js documentation site (docs.useplunk.com)
- **landing**: Next.js marketing site (next.useplunk.com)
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
### Shared Packages (`packages/`)
@@ -135,7 +135,7 @@ between groups.
## Need Help?
- Check the [documentation](https://docs.useplunk.com)
- Check the [documentation](https://next-wiki.useplunk.com)
- Open an issue for bugs or feature requests
- Join our community discussions
+34 -6
View File
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
# Build-time arguments for URL configuration
# These are only used during the build process (for wiki OpenAPI generation and static assets)
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
ARG API_URI=https://api.useplunk.com
ARG DASHBOARD_URI=https://app.useplunk.com
ARG LANDING_URI=https://www.useplunk.com
ARG WIKI_URI=https://docs.useplunk.com
ARG API_URI=https://next-api.useplunk.com
ARG DASHBOARD_URI=https://next-app.useplunk.com
ARG LANDING_URI=https://next.useplunk.com
ARG WIKI_URI=https://next-wiki.useplunk.com
WORKDIR /app
@@ -123,6 +123,10 @@ COPY --from=deps /app/yarn.lock ./
# Copy root config files needed for Turbo
COPY turbo.json ./
# Copy manifest generation script
COPY docker/generate-url-manifest.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
# Step 1: Copy and build shared packages (these change less frequently)
# Shared packages are dependencies for apps, so build them first
COPY packages ./packages
@@ -171,6 +175,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=wiki
# Generate sitemap for wiki
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
# Generate URL replacement manifest for wiki (build-time optimization)
RUN generate-url-manifest.sh wiki /app/apps/wiki
# Step 4: Copy and build Web dashboard
COPY apps/web ./apps/web
@@ -184,6 +192,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=web
# Generate sitemap for web
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
# Generate URL replacement manifest for web (build-time optimization)
RUN generate-url-manifest.sh web /app/apps/web
# Step 5: Copy and build Landing page
COPY apps/landing ./apps/landing
@@ -197,6 +209,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=landing
# Generate sitemap for landing
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
# Generate URL replacement manifest for landing (build-time optimization)
RUN generate-url-manifest.sh landing /app/apps/landing
# Copy any remaining root files (if needed)
COPY . .
@@ -257,8 +273,7 @@ COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/sh
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
# @plunk/types exports source TypeScript files directly (no build output)
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
# Copy Prisma schema (needed for migrations at runtime)
@@ -284,17 +299,26 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
# Landing app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
# Wiki app - standalone build with static assets and OpenAPI spec
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
# Copy full .next directories for the entrypoint script (URL replacement via find command)
# These are much smaller than node_modules and needed for runtime URL replacement
@@ -311,6 +335,10 @@ COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./app
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
RUN chmod +x /app/docker/nginx/setup-nginx.sh
# Copy optimized URL replacement script
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
RUN chmod +x /app/docker/replace-urls-optimized.sh
# Copy entrypoint script
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
+9 -5
View File
@@ -7,10 +7,14 @@
</p>
<p align="center">
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
<img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml"/>
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
</p>
## Introduction
@@ -47,7 +51,7 @@ the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
<a href="https://github.com/useplunk/plunk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
</a>
## License
+14
View File
@@ -54,6 +54,12 @@ AWS_SES_SECRET_ACCESS_KEY=
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
# Email sending rate limit (emails per second)
# If not set, automatically fetches from AWS SES account quota (recommended)
# Set this to override AWS quota (useful for setting lower limits or testing)
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
@@ -70,3 +76,11 @@ STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
# ==============================================================================
# Security (Optional)
# ==============================================================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
+2
View File
@@ -22,6 +22,7 @@
"@plunk/shared": "*",
"@plunk/types": "*",
"@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.63.2",
@@ -34,6 +35,7 @@
"helmet": "^8.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"signale": "^1.4.0",
@@ -10,7 +10,7 @@ import {
NotAuthenticated,
NotFound,
RateLimitError,
ValidationError
ValidationError,
} from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.js';
@@ -80,6 +80,7 @@ describe('Actions API Integration Tests', () => {
it('should validate subject and body required when no template', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
// Missing subject, body, and template
});
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as string (backward compatible)', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
name: 'Jane Doe',
email: '[email protected]',
},
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
to: {
email: '[email protected]',
},
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as array of strings', () => {
const result = ActionSchemas.send.safeParse({
to: ['[email protected]', '[email protected]'],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
{name: 'Jane Doe', email: '[email protected]'},
{name: 'John Smith', email: '[email protected]'},
],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as mixed array of strings and objects', () => {
const result = ActionSchemas.send.safeParse({
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
from: '[email protected]',
subject: 'Test',
body: 'Test',
});
@@ -442,4 +449,588 @@ describe('Actions API Integration Tests', () => {
}
});
});
// ========================================
// RESERVED EVENT VALIDATION
// ========================================
describe('Reserved Event Validation', () => {
describe('Email events (email.*)', () => {
it('should reject email.sent event', () => {
const result = ActionSchemas.track.safeParse({
event: 'email.sent',
email: '[email protected]',
});
// Schema allows it, but controller validation should reject
expect(result.success).toBe(true);
// Verify the error would be thrown by controller
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.sent',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
expect(error.errors[0]?.code).toBe('reserved_event');
});
it('should reject email.delivery event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.delivery" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.delivery',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.field).toBe('event');
});
it('should reject email.open event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.open" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.click event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.click" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.bounce event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.bounce" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject email.complaint event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.complaint" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should reject any email.* pattern', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.custom" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
});
describe('Contact events', () => {
it('should reject contact.subscribed event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "contact.subscribed" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.field).toBe('event');
});
it('should reject contact.unsubscribed event', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "contact.unsubscribed" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should allow other contact.* events', () => {
const result1 = ActionSchemas.track.safeParse({
event: 'contact.created',
email: '[email protected]',
});
const result2 = ActionSchemas.track.safeParse({
event: 'contact.updated',
email: '[email protected]',
});
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
});
describe('Segment events', () => {
it('should reject segment.*.entry events', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "segment.vip-users.entry" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
expect(error.errors[0]?.code).toBe('reserved_event');
});
it('should reject segment.*.exit events', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "segment.premium.exit" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
},
],
'Cannot track reserved system event',
);
expect(error.code).toBe(422);
});
it('should allow other segment.* events', () => {
const result1 = ActionSchemas.track.safeParse({
event: 'segment.created',
email: '[email protected]',
});
const result2 = ActionSchemas.track.safeParse({
event: 'segment.premium.updated',
email: '[email protected]',
});
expect(result1.success).toBe(true);
expect(result2.success).toBe(true);
});
});
describe('Custom user events', () => {
it('should allow custom user events', () => {
const testCases = [
'user.signup',
'purchase.completed',
'order.placed',
'custom.event',
'product.viewed',
'cart.abandoned',
];
for (const eventName of testCases) {
const result = ActionSchemas.track.safeParse({
event: eventName,
email: '[email protected]',
});
expect(result.success).toBe(true);
}
});
it('should allow events with similar but different prefixes', () => {
const testCases = ['emails.sent', 'contacts.subscribed', 'segments.entry'];
for (const eventName of testCases) {
const result = ActionSchemas.track.safeParse({
event: eventName,
email: '[email protected]',
});
expect(result.success).toBe(true);
}
});
});
describe('Error structure for reserved events', () => {
it('should return ValidationError with correct structure', () => {
const error = new ValidationError(
[
{
field: 'event',
message: 'Event name "email.sent" is reserved for system use and cannot be manually tracked',
code: 'reserved_event',
received: 'email.sent',
},
],
'Cannot track reserved system event',
);
expect(error).toBeInstanceOf(ValidationError);
expect(error.code).toBe(422);
expect(error.errorCode).toBe(ErrorCode.VALIDATION_ERROR);
expect(error.message).toBe('Cannot track reserved system event');
expect(error.errors).toHaveLength(1);
expect(error.errors[0]).toMatchObject({
field: 'event',
code: 'reserved_event',
received: 'email.sent',
});
});
});
});
// ========================================
// SUBSCRIPTION STATUS PRESERVATION
// ========================================
describe('Subscription Status Preservation', () => {
describe('/v1/send endpoint', () => {
it('should NOT change subscription status when sending to subscribed contact without subscribed field', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Send transactional email without specifying subscribed field
await EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
// Verify subscription status unchanged
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(true);
});
it('should NOT change subscription status when sending to unsubscribed contact without subscribed field', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Send transactional email without specifying subscribed field
await EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
// Verify subscription status unchanged (should still be false)
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(false);
});
it('should allow explicit subscription when subscribed=true is provided', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// This test would need to be implemented at the controller level
// since EmailService.sendTransactionalEmail doesn't accept subscribed parameter
// For now, verify the schema allows it
const result = ActionSchemas.send.safeParse({
to: contact.email,
subject: 'Test',
body: 'Test',
from: '[email protected]',
subscribed: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(true);
}
});
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
// Verify the schema allows explicit false
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: '[email protected]',
subscribed: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(false);
}
});
it('should default to undefined when subscribed field is omitted', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
from: '[email protected]',
});
expect(result.success).toBe(true);
if (result.success) {
// Should be undefined, not false
expect(result.data.subscribed).toBeUndefined();
}
});
});
describe('/v1/track endpoint', () => {
it('should NOT change subscription status when tracking event for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Track event without specifying subscribed field
// This would be done via ContactService.upsert in the track endpoint
const {ContactService} = await import('../../services/ContactService.js');
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
// Verify subscription status unchanged
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when tracking event', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Track event without specifying subscribed field
const {ContactService} = await import('../../services/ContactService.js');
await ContactService.upsert(projectId, contact.email, {event: 'test'}, undefined);
// Verify subscription status unchanged (should still be false, NOT re-subscribed)
const updatedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updatedContact?.subscribed).toBe(false);
});
it('should create new contacts as subscribed when subscribed is undefined', async () => {
const newEmail = '[email protected]';
// Track event for new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should allow explicit subscription when subscribed=true is provided', async () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
subscribed: true,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(true);
}
});
it('should allow explicit unsubscription when subscribed=false is provided', async () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
subscribed: false,
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.subscribed).toBe(false);
}
});
it('should default to undefined when subscribed field is omitted', () => {
const result = ActionSchemas.track.safeParse({
event: 'test',
email: '[email protected]',
});
expect(result.success).toBe(true);
if (result.success) {
// Should be undefined, not true
expect(result.data.subscribed).toBeUndefined();
}
});
});
describe('ContactService.upsert behavior', () => {
it('should preserve subscription status when undefined is passed for existing contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Update with undefined subscribed
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should preserve unsubscribed status when undefined is passed', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Update with undefined subscribed
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should allow explicit subscription change to true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Explicitly subscribe
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should allow explicit subscription change to false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
const {ContactService} = await import('../../services/ContactService.js');
// Explicitly unsubscribe
await ContactService.upsert(projectId, contact.email, {}, false);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
});
});
@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Note: To run these integration tests, you need to:
+94 -10
View File
@@ -14,6 +14,7 @@ import {
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
PLUNK_ENABLED,
PORT,
S3_ENABLED,
SMTP_ENABLED,
@@ -75,23 +76,73 @@ const server = new (class extends Server {
// Log all requests to database for historical tracking and analytics
this.app.use(databaseRequestLogger);
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
res.set({'Access-Control-Allow-Origin': '*'});
next();
});
// Build allowed origins from environment variables
const allowedOrigins =
NODE_ENV === 'development'
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
this.app.use(
// Public API endpoints that should allow all origins
const publicApiPaths = ['/v1', '/v1/track', '/v1/send'];
// Log CORS configuration on startup
signale.info('CORS configuration', {
environment: NODE_ENV,
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
publicApiPaths,
});
// Apply restrictive CORS to all routes EXCEPT public API endpoints
this.app.use((req, res, next) => {
// Check if this is a public API endpoint
const isPublicApi = publicApiPaths.some(path => req.path === path || req.path.startsWith(path + '/'));
if (isPublicApi) {
// For public API endpoints, allow all origins
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
});
// Handle preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
return next();
}
// For other endpoints, apply restrictive CORS
cors({
origin: allowedOrigins,
origin: (origin, callback) => {
// Allow requests with no origin (e.g., mobile apps, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
// Check if origin matches any allowed origin (string or regex)
const isAllowed = allowedOrigins.some(allowed => {
if (allowed instanceof RegExp) {
return allowed.test(origin);
}
return allowed === origin;
});
if (isAllowed) {
callback(null, true);
} else {
// Log CORS rejection with helpful information
signale.warn('CORS request rejected', {
origin,
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
hint: 'If using HTTPS, ensure USE_HTTPS=true is set in your environment variables',
});
// Reject the CORS request by passing false (don't send CORS headers)
callback(null, false);
}
},
credentials: true,
}),
);
})(req, res, next);
});
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
@@ -153,6 +204,34 @@ interface ErrorResponse {
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
const requestId = res.locals.requestId as string | undefined;
// Handle JSON parsing errors (from express.json() middleware)
if (error instanceof SyntaxError && 'body' in error) {
const statusCode = 400;
logger.warn(
'JSON parsing failed',
{
endpoint: `${req.method} ${req.path}`,
contentType: req.get('content-type'),
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.VALIDATION_ERROR,
message: 'Invalid JSON in request body',
statusCode,
requestId,
suggestion: 'Ensure your request body is valid JSON and Content-Type header is set to "application/json".',
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle Zod validation errors
if (error instanceof ZodError) {
const fieldErrors: FieldError[] = error.errors.map(err => ({
@@ -309,7 +388,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
// Global error handlers to prevent server crashes
process.on('unhandledRejection', (reason, promise) => {
signale.error('Unhandled Promise Rejection:', reason);
console.error('Promise:', promise);
signale.error('Promise:', promise);
// Don't exit the process - just log the error
});
@@ -351,6 +430,11 @@ void prisma.$connect().then(async () => {
? 'Per-project tracking toggle enabled'
: 'Always tracking or always no-tracking',
},
{
name: 'Platform emails',
enabled: PLUNK_ENABLED,
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
},
];
const rows = features.map(f => ({
+22
View File
@@ -45,6 +45,13 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing)
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
@@ -87,3 +94,18 @@ export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587')
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
export const SMTP_ENABLED =
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Security (optional)
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
+104 -16
View File
@@ -1,20 +1,20 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {EventService} from '../services/EventService.js';
import {NotFound} from '../exceptions/index.js';
import {NotFound, ValidationError} from '../exceptions/index.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {DASHBOARD_URI} from '../app/constants.js';
/**
* Public API Actions Controller
* Handles track event and transactional email endpoints
* Handles track event, transactional email, and email verification endpoints
*/
@Controller('v1')
export class Actions {
@@ -25,7 +25,7 @@ export class Actions {
* Request body:
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional, default: true) - Contact subscription status
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
@@ -50,11 +50,26 @@ export class Actions {
@Middleware([requirePublicKey])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
// Prevent manual tracking of reserved system events
if (EventService.isReservedEvent(event)) {
throw new ValidationError(
[
{
field: 'event',
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
code: 'reserved_event',
received: event,
},
],
'Cannot track reserved system event',
);
}
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
const contact = await ContactService.upsert(
@@ -95,7 +110,7 @@ export class Actions {
* - Array: ["[email protected]", {name: "Jane", email: "[email protected]"}]
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional, default: false) - Contact subscription status
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
@@ -156,7 +171,7 @@ export class Actions {
@Middleware([requireSecretKey])
@CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
@@ -223,14 +238,21 @@ export class Actions {
templateId = templateRecord.id;
}
// Verify 'from' domain is verified if provided
const senderEmail = emailFrom || '[email protected]'; // Default sender
// Only verify custom domains (not the default [email protected])
if (emailFrom && emailFrom !== '[email protected]') {
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
if (!emailFrom) {
throw new ValidationError(
[
{
field: 'from',
message: 'Sender email is required either in request or template',
code: 'required',
},
],
'Could not parse sender email',
);
}
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
const replyToEmail = emailReplyTo;
const timestamp = new Date();
@@ -249,12 +271,24 @@ export class Actions {
// Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
// Add system variables (email, unsubscribe URLs, etc.) to merged data
// These are always available for template rendering
const dataWithSystemVars = {
...mergedData,
id: contact.id,
email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
// Render template with contact data
// Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!;
let renderedBody = emailBody!;
for (const [key, value] of Object.entries(mergedData)) {
for (const [key, value] of Object.entries(dataWithSystemVars)) {
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
@@ -281,7 +315,7 @@ export class Actions {
contactId: contact.id,
subject: renderedSubject,
body: renderedBody,
from: senderEmail,
from: emailFrom,
fromName: emailFromName,
toName: recipient.name,
replyTo: replyToEmail,
@@ -307,4 +341,58 @@ export class Actions {
},
});
}
/**
* POST /v1/verify
* Verify an email address
*
* Request body:
* - email: string (required) - Email address to verify
*
* Response:
* - success: boolean
* - data: object with verification results
* - email: string - Email address that was verified
* - valid: boolean - Whether the email appears to be valid
* - isDisposable: boolean - Whether the email is from a disposable domain
* - hasMxRecords: boolean - Whether the domain has MX records configured
* - suggestedEmail?: string - Suggested correction if typo detected
* - reasons: string[] - Array of reasons describing the verification results
*
* Example:
* {
* email: "[email protected]"
* }
*
* Response:
* {
* success: true,
* data: {
* email: "[email protected]",
* valid: false,
* isDisposable: false,
* hasMxRecords: false,
* suggestedEmail: "[email protected]",
* reasons: [
* "Possible typo detected, did you mean [email protected]?",
* "Domain does not exist or has no MX records"
* ]
* }
* }
*/
@Post('verify')
@Middleware([requireSecretKey])
@CatchAsync
public async verify(req: Request, res: Response, _next: NextFunction) {
// Zod validation - errors automatically handled by global error handler
const {email} = ActionSchemas.verify.parse(req.body);
// Verify the email address
const verificationResult = await EmailVerificationService.verifyEmail(email);
return res.status(200).json({
success: true,
data: verificationResult,
});
}
}
+12 -13
View File
@@ -1,9 +1,8 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
import {ActivityType} from '@plunk/types';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity')
@@ -21,10 +20,10 @@ export class Activity {
* - endDate: ISO date string
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId as string | undefined;
@@ -62,10 +61,10 @@ export class Activity {
* - endDate: ISO date string (defaults to now)
*/
@Get('stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -82,10 +81,10 @@ export class Activity {
* - minutes: number (default 5)
*/
@Get('recent-count')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
@@ -98,7 +97,7 @@ export class Activity {
* Get available activity types (for UI filters)
*/
@Get('types')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType);
@@ -114,10 +113,10 @@ export class Activity {
* - daysAhead: number (default 30, max 90)
*/
@Get('upcoming')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
+9 -11
View File
@@ -1,8 +1,6 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,10 +17,10 @@ export class Analytics {
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/
@Get('timeseries')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -41,10 +39,10 @@ export class Analytics {
* - endDate: ISO date string (defaults to now)
*/
@Get('top-campaigns')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -65,10 +63,10 @@ export class Analytics {
* Returns aggregate stats: total campaigns, active, completed, average rates
*/
@Get('campaign-stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -89,10 +87,10 @@ export class Analytics {
* Returns events sorted by frequency with trend data
*/
@Get('top-events')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
+220 -2
View File
@@ -1,12 +1,27 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';
import {GITHUB_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED} from '../app/constants.js';
import {
DASHBOARD_URI,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {jwt} from '../middleware/auth.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
@@ -50,6 +65,31 @@ export class Auth {
public async signup(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
// 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({
success: false,
data: 'This email address cannot be used for signup',
});
}
const user = await UserService.email(email);
if (user) {
@@ -64,6 +104,8 @@ export class Auth {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
// Auto-verify email if platform emails are disabled
emailVerified: !PLUNK_ENABLED,
},
});
@@ -72,6 +114,27 @@ export class Auth {
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
// Send email verification if platform emails are enabled
if (PLUNK_ENABLED) {
const verificationToken = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(verificationToken),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
);
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
await sendPlatformEmail(
created_user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {
email: created_user.email,
verificationUrl,
landingUrl: LANDING_URI,
}),
);
}
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
@@ -97,4 +160,159 @@ export class Auth {
},
});
}
@Post('verify-email')
@CatchAsync
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
// Look up token in Redis
const data = await redis.get(Keys.User.emailVerificationToken(token));
if (!data) {
throw new BadRequest('Invalid or expired verification token');
}
const {userId} = JSON.parse(data);
// Update user
await prisma.user.update({
where: {id: userId},
data: {emailVerified: true},
});
// Delete token (single use) and invalidate cache
await redis.del(Keys.User.emailVerificationToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Email verified successfully'}});
}
@Post('request-verification')
@CatchAsync
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
const userId = parseJwt(req);
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
if (user.emailVerified) {
return res.json({success: true, data: {message: 'Email already verified'}});
}
// Check rate limit
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
throw new RateLimitError('Too many verification emails sent. Please try again later.');
}
// Generate token
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
);
// Send email
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
await sendPlatformEmail(
user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
return res.json({success: true, data: {message: 'Verification email sent'}});
}
@Post('request-password-reset')
@CatchAsync
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
// Check rate limit
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
// Still return success to prevent enumeration
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
// Look up user
const user = await UserService.email(email);
// Only send email if user exists and is PASSWORD type
if (user && user.type === 'PASSWORD') {
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.passwordResetToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
);
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
await sendPlatformEmail(
user.email,
'Reset your password',
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
}
// Always return success (prevent enumeration)
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
@Post('reset-password')
@CatchAsync
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
// Look up token
const data = await redis.get(Keys.User.passwordResetToken(token));
if (!data) {
throw new BadRequest('Invalid or expired reset token');
}
const {userId} = JSON.parse(data);
// Get user and verify type
const user = await prisma.user.findUnique({where: {id: userId}});
if (!user || user.type !== 'PASSWORD') {
throw new BadRequest('Invalid reset token');
}
// Hash new password and update
const hashedPassword = await AuthService.generateHash(newPassword);
await prisma.user.update({
where: {id: userId},
data: {password: hashedPassword},
});
// Delete token and invalidate cache
await redis.del(Keys.User.passwordResetToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Password reset successfully'}});
}
}
+31 -39
View File
@@ -1,11 +1,10 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -17,14 +16,13 @@ export class Campaigns {
* POST /campaigns
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
@@ -60,10 +58,10 @@ export class Campaigns {
* GET /campaigns
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -79,13 +77,7 @@ export class Campaigns {
pageSize,
});
return res.json({
campaigns: result.campaigns,
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
});
return res.json(result);
}
/**
@@ -93,11 +85,11 @@ export class Campaigns {
* GET /campaigns/:id
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!);
@@ -112,11 +104,11 @@ export class Campaigns {
* PUT /campaigns/:id
*/
@Put(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body;
@@ -158,11 +150,11 @@ export class Campaigns {
* DELETE /campaigns/:id
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!);
@@ -177,11 +169,11 @@ export class Campaigns {
* POST /campaigns/:id/duplicate
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!);
@@ -197,11 +189,11 @@ export class Campaigns {
* POST /campaigns/:id/send
*/
@Post(':id/send')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided
@@ -228,11 +220,11 @@ export class Campaigns {
* POST /campaigns/:id/cancel
*/
@Post(':id/cancel')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!);
@@ -248,11 +240,11 @@ export class Campaigns {
* GET /campaigns/:id/stats
*/
@Get(':id/stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!);
@@ -267,11 +259,11 @@ export class Campaigns {
* POST /campaigns/:id/test
*/
@Post(':id/test')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email);
+175 -30
View File
@@ -1,9 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +14,6 @@ const upload = multer({
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true);
} else {
@@ -31,10 +29,10 @@ export class Contacts {
* List all contacts for the authenticated project with cursor-based pagination
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const search = req.query.search as string | undefined;
@@ -50,10 +48,10 @@ export class Contacts {
* Returns field names with inferred types (string, number, boolean, date)
*/
@Get('fields')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
@@ -63,7 +61,7 @@ export class Contacts {
count: fieldsWithTypes.length,
});
} catch (error) {
console.error('[CONTACTS] Failed to get available fields:', error);
signale.error('[CONTACTS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
@@ -76,10 +74,10 @@ export class Contacts {
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/
@Get('fields/:field/values')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
@@ -97,7 +95,7 @@ export class Contacts {
limit,
});
} catch (error) {
console.error('[CONTACTS] Failed to get field values:', error);
signale.error('[CONTACTS] Failed to get field values:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values',
});
@@ -109,10 +107,10 @@ export class Contacts {
* Get a specific contact by ID
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
@@ -129,10 +127,10 @@ export class Contacts {
* Create or update a contact (upsert)
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {email, data, subscribed} = req.body;
if (!email) {
@@ -159,10 +157,10 @@ export class Contacts {
* Update a contact
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
const {email, data, subscribed} = req.body;
@@ -180,10 +178,10 @@ export class Contacts {
* Delete a contact
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
@@ -210,10 +208,24 @@ export class Contacts {
const contact = await ContactService.getById(contactId);
// Fetch project to get language preference
const project = await ContactService.getProjectByContactId(contactId);
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
language: contactLocale || project?.language || 'en',
});
}
@@ -269,7 +281,7 @@ export class Contacts {
@Middleware([requireAuth, upload.single('file')])
@CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!req.file) {
return res.status(400).json({error: 'CSV file is required'});
@@ -288,7 +300,7 @@ export class Contacts {
jobId: job.id,
});
} catch (error) {
console.error('[CONTACTS] Failed to queue import:', error);
signale.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import',
});
@@ -300,7 +312,7 @@ export class Contacts {
* Get import job status
*/
@Get('import/:jobId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
@@ -318,7 +330,7 @@ export class Contacts {
return res.status(200).json(status);
} catch (error) {
console.error('[CONTACTS] Failed to get import status:', error);
signale.error('[CONTACTS] Failed to get import status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status',
});
@@ -331,10 +343,10 @@ export class Contacts {
* Returns information about where the field is used and whether it can be safely deleted
*/
@Get('fields/:field/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
@@ -345,7 +357,7 @@ export class Contacts {
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
return res.status(200).json(usage);
} catch (error) {
console.error('[CONTACTS] Failed to get field usage:', error);
signale.error('[CONTACTS] Failed to get field usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field usage',
});
@@ -358,10 +370,10 @@ export class Contacts {
* Only works if the field is not used in any segments or campaigns
*/
@Delete('fields/:field')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
@@ -372,10 +384,143 @@ export class Contacts {
const result = await ContactService.deleteField(auth.projectId!, field);
return res.status(200).json(result);
} catch (error) {
console.error('[CONTACTS] Failed to delete field:', error);
signale.error('[CONTACTS] Failed to delete field:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete field',
});
}
}
/**
* POST /contacts/bulk-subscribe
* Queue bulk subscribe operation
*/
@Post('bulk-subscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
// Validate limit
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
return res.status(202).json({
message: 'Bulk subscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
});
}
}
/**
* POST /contacts/bulk-unsubscribe
* Queue bulk unsubscribe operation
*/
@Post('bulk-unsubscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
return res.status(202).json({
message: 'Bulk unsubscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
});
}
}
/**
* POST /contacts/bulk-delete
* Queue bulk delete operation
*/
@Post('bulk-delete')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
return res.status(202).json({
message: 'Bulk delete queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
});
}
}
/**
* GET /contacts/bulk/:jobId
* Get bulk action job status
*/
@Get('bulk/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getBulkActionJobStatus(jobId);
if (!status) {
return res.status(404).json({error: 'Bulk action job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get bulk action status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
});
}
}
}
+14 -57
View File
@@ -4,11 +4,10 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js';
import {MembershipService} from '../services/MembershipService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
@@ -17,23 +16,14 @@ export class Domains {
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
@@ -44,10 +34,10 @@ export class Domains {
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) {
@@ -55,19 +45,7 @@ export class Domains {
}
// Verify user has admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission');
}
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
@@ -104,10 +82,10 @@ export class Domains {
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
@@ -117,16 +95,7 @@ export class Domains {
}
// Verify user has access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, domain.projectId);
const verificationStatus = await DomainService.checkVerification(id);
@@ -141,10 +110,10 @@ export class Domains {
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
@@ -154,19 +123,7 @@ export class Domains {
}
// Verify user has admin access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have permission');
}
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
await DomainService.removeDomain(id);
+18 -19
View File
@@ -1,8 +1,7 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,10 +12,10 @@ export class Events {
* Track a custom event (can trigger workflows)
*/
@Post('track')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, contactId, emailId, data} = req.body;
if (!name) {
@@ -33,10 +32,10 @@ export class Events {
* List events for the project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100;
@@ -50,10 +49,10 @@ export class Events {
* Get event statistics
*/
@Get('stats')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -67,10 +66,10 @@ export class Events {
* Get events for a specific contact
*/
@Get('contact/:contactId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50;
@@ -88,10 +87,10 @@ export class Events {
* Get unique event names for the project
*/
@Get('names')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
@@ -104,10 +103,10 @@ export class Events {
* Returns information about where the event is used and whether it can be safely deleted
*/
@Get(':eventName/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
@@ -118,7 +117,7 @@ export class Events {
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
return res.status(200).json(usage);
} catch (error) {
console.error('[EVENTS] Failed to get event usage:', error);
signale.error('[EVENTS] Failed to get event usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get event usage',
});
@@ -131,10 +130,10 @@ export class Events {
* Only works if the event is not used in any segments or workflows
*/
@Delete(':eventName')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
@@ -145,7 +144,7 @@ export class Events {
const result = await EventService.deleteEvent(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
console.error('[EVENTS] Failed to delete event:', error);
signale.error('[EVENTS] Failed to delete event:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete event',
});
+1
View File
@@ -85,6 +85,7 @@ export class Github {
data: {
email,
type: 'GITHUB_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
+1
View File
@@ -75,6 +75,7 @@ export class Google {
data: {
email,
type: 'GOOGLE_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
+53 -172
View File
@@ -1,11 +1,12 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {MembershipSchemas} from '@plunk/shared';
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('projects')
@@ -15,23 +16,14 @@ export class Projects {
* GET /projects/:id/setup-state
*/
@Get(':id/setup-state')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get project with relevant data
const project = await prisma.project.findUnique({
@@ -83,51 +75,49 @@ export class Projects {
});
}
/**
* Get project security metrics
* GET /projects/:id/security
*/
@Get(':id/security')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.json({
success: true,
data: metrics,
});
}
/**
* Get all members of a project
* GET /projects/:id/members
*/
@Get(':id/members')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get all members of the project
const members = await prisma.membership.findMany({
where: {
projectId: id,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
const members = await MembershipService.getMembers(id);
return res.json({
success: true,
data: members.map(m => ({
userId: m.user.id,
email: m.user.email,
role: m.role,
})),
data: members,
});
}
@@ -137,11 +127,11 @@ export class Projects {
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/
@Post(':id/members')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Validate params
if (!id) {
@@ -157,19 +147,7 @@ export class Projects {
const {email, role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can add members');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Find user by email
const userToAdd = await prisma.user.findUnique({
@@ -181,28 +159,8 @@ export class Projects {
throw new HttpException(404, 'User with this email does not have an account');
}
// Check if user is already a member
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId: userToAdd.id,
projectId: id,
},
},
});
if (existingMembership) {
throw new HttpException(409, 'User is already a member of this project');
}
// Create membership
const newMembership = await prisma.membership.create({
data: {
userId: userToAdd.id,
projectId: id,
role,
},
});
// Add member to project
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
return res.json({
success: true,
@@ -220,10 +178,10 @@ export class Projects {
* Body: { role: 'ADMIN' | 'MEMBER' }
*/
@Patch(':id/members/:userId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
@@ -243,38 +201,7 @@ export class Projects {
const {role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can update member roles');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot change OWNER role
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot change the role of the project owner');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get user info
const user = await prisma.user.findUnique({
@@ -286,16 +213,8 @@ export class Projects {
throw new HttpException(404, 'User not found');
}
// Update role
await prisma.membership.update({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
data: {role},
});
// Update role (service handles validation)
await MembershipService.updateRole(id, userId, role);
return res.json({
success: true,
@@ -312,10 +231,10 @@ export class Projects {
* DELETE /projects/:id/members/:userId
*/
@Delete(':id/members/:userId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
@@ -327,53 +246,15 @@ export class Projects {
}
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can remove members');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot remove OWNER
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot remove the project owner');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Cannot remove yourself
if (userId === auth.userId) {
throw new HttpException(403, 'You cannot remove yourself from the project');
}
// Delete membership
await prisma.membership.delete({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
// Remove member (service handles validation)
await MembershipService.removeMember(id, userId);
return res.json({
success: true,
+17 -19
View File
@@ -1,8 +1,6 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,10 +11,10 @@ export class Segments {
* List all segments for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segments = await SegmentService.list(auth.projectId!);
@@ -28,10 +26,10 @@ export class Segments {
* Get a specific segment by ID with member count
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -48,10 +46,10 @@ export class Segments {
* Get contacts that match a segment's filters
*/
@Get(':id/contacts')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -70,10 +68,10 @@ export class Segments {
* Create a new segment
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, condition, trackMembership} = req.body;
if (!name) {
@@ -99,10 +97,10 @@ export class Segments {
* Update a segment
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body;
@@ -129,10 +127,10 @@ export class Segments {
* Delete a segment
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -149,10 +147,10 @@ export class Segments {
* Recompute segment membership for all contacts
*/
@Post(':id/compute')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -169,10 +167,10 @@ export class Segments {
* Refresh segment member count
*/
@Post(':id/refresh')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
+15 -17
View File
@@ -1,9 +1,7 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,10 +13,10 @@ export class Templates {
* List all templates for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
@@ -34,10 +32,10 @@ export class Templates {
* Get a specific template by ID
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -54,10 +52,10 @@ export class Templates {
* Create a new template
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) {
@@ -98,10 +96,10 @@ export class Templates {
* Update a template
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
@@ -133,10 +131,10 @@ export class Templates {
* Delete a template
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -153,10 +151,10 @@ export class Templates {
* Duplicate a template
*/
@Post(':id/duplicate')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -173,10 +171,10 @@ export class Templates {
* Get template usage statistics
*/
@Get(':id/usage')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
+5 -7
View File
@@ -1,9 +1,8 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,7 +13,6 @@ const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) {
@@ -32,10 +30,10 @@ export class Uploads {
* Upload an image file to S3/Minio
*/
@Post('image')
@Middleware([requireAuth, upload.single('image')])
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
try {
if (!S3Service.isS3Enabled()) {
@@ -66,7 +64,7 @@ export class Uploads {
size: req.file.size,
});
} catch (error) {
console.error('[UPLOADS] Failed to upload image:', error);
signale.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image',
});
+114 -181
View File
@@ -1,16 +1,16 @@
import {randomBytes} from 'node:crypto';
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {MembershipService} from '../services/MembershipService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js';
@@ -20,10 +20,10 @@ import signale from 'signale';
@Controller('users')
export class Users {
@Get('@me')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
@@ -39,10 +39,10 @@ export class Users {
}
@Get('@me/projects')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
@@ -54,15 +54,25 @@ export class Users {
}
@Post('@me/projects')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
}
// Check if user is a member of any disabled project
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
ErrorCode.PROJECT_DISABLED,
);
}
const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys
@@ -91,27 +101,15 @@ export class Users {
}
@Patch('@me/projects/:id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update it');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Update the project
const project = await prisma.project.update({
@@ -123,26 +121,14 @@ export class Users {
}
@Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to regenerate keys');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
@@ -175,31 +161,20 @@ export class Users {
}
@Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -235,6 +210,17 @@ export class Users {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
// Validate currency if provided
let checkoutCurrency: string | undefined;
if (currency && typeof currency === 'string') {
const validCurrencies = ['usd', 'eur', 'gbp'];
if (validCurrencies.includes(currency.toLowerCase())) {
checkoutCurrency = currency.toLowerCase();
} else {
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
}
}
// Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
@@ -243,6 +229,7 @@ export class Users {
customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
@@ -254,31 +241,19 @@ export class Users {
}
@Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -304,11 +279,11 @@ export class Users {
}
@Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -319,16 +294,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing limits');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -337,11 +303,11 @@ export class Users {
}
@Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -354,25 +320,16 @@ export class Users {
const data = BillingLimitSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
await MembershipService.requireAdminAccess(auth.userId!, id);
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
// Get the project
// Get the project with current limits
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
},
});
@@ -395,8 +352,17 @@ export class Users {
},
});
// Invalidate cache so new limits take effect immediately
await BillingLimitService.invalidateCache(id);
// Clear notification cache keys for limits that changed
// This allows new warning/limit emails to be sent when the new limits are reached
await BillingLimitService.clearNotificationCacheForChangedLimits(
id,
{
workflows: project.billingLimitWorkflows,
campaigns: project.billingLimitCampaigns,
transactional: project.billingLimitTransactional,
},
data,
);
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -404,11 +370,11 @@ export class Users {
}
@Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -424,16 +390,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
await MembershipService.requireAccess(auth.userId!, id);
const project = await prisma.project.findUnique({
where: {id},
@@ -530,11 +487,11 @@ export class Users {
}
@Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
@@ -550,16 +507,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -618,11 +566,11 @@ export class Users {
}
@Get('@me/projects/:id/security')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -633,16 +581,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view security metrics');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id);
@@ -651,11 +590,11 @@ export class Users {
}
@Post('@me/projects/:id/reset')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -666,18 +605,16 @@ export class Users {
}
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
await MembershipService.requireAdminAccess(auth.userId!, id);
if (!membership) {
throw new NotFound('Project not found or you do not have permission to reset it');
// Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id);
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
ErrorCode.PROJECT_DISABLED,
);
}
// Delete all project data in a transaction
@@ -727,11 +664,11 @@ export class Users {
}
@Delete('@me/projects/:id')
@Middleware([isAuthenticated])
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = req.params;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
@@ -742,29 +679,16 @@ export class Users {
}
// Verify user has owner or admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['OWNER', 'ADMIN'],
},
},
});
await MembershipService.requireAdminAccess(auth.userId!, id);
if (!membership) {
throw new NotFound(
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
);
}
// Get project to check for active subscription
// Get project to check for active subscription and disabled status
const project = await prisma.project.findUnique({
where: {id},
select: {
name: true,
subscription: true,
customer: true,
disabled: true,
},
});
@@ -772,6 +696,15 @@ export class Users {
throw new NotFound('Project not found');
}
// Check if project is disabled - block delete operation
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
ErrorCode.PROJECT_DISABLED,
);
}
// If project has an active subscription, cancel it first
if (STRIPE_ENABLED && stripe && project.subscription) {
try {
+35 -36
View File
@@ -1,9 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,10 +13,10 @@ export class Workflows {
* List all workflows for the authenticated project
*/
@Get('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
@@ -34,10 +33,10 @@ export class Workflows {
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/
@Get('fields')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
try {
@@ -45,7 +44,7 @@ export class Workflows {
return res.status(200).json(result);
} catch (error) {
console.error('[WORKFLOWS] Failed to get available fields:', error);
signale.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
@@ -57,10 +56,10 @@ export class Workflows {
* Get a specific workflow with all steps and transitions
*/
@Get(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
@@ -77,10 +76,10 @@ export class Workflows {
* Create a new workflow
*/
@Post('')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) {
@@ -107,10 +106,10 @@ export class Workflows {
* Update a workflow
*/
@Patch(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
@@ -135,10 +134,10 @@ export class Workflows {
* Delete a workflow
*/
@Delete(':id')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
@@ -155,10 +154,10 @@ export class Workflows {
* Add a step to a workflow
*/
@Post(':id/steps')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body;
@@ -187,10 +186,10 @@ export class Workflows {
* Update a workflow step
*/
@Patch(':id/steps/:stepId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body;
@@ -214,10 +213,10 @@ export class Workflows {
* Delete a workflow step
*/
@Delete(':id/steps/:stepId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
@@ -235,10 +234,10 @@ export class Workflows {
* Create a transition between steps
*/
@Post(':id/transitions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
@@ -265,10 +264,10 @@ export class Workflows {
* Delete a transition
*/
@Delete(':id/transitions/:transitionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const transitionId = req.params.transitionId;
@@ -286,10 +285,10 @@ export class Workflows {
* Start a workflow execution for a contact
*/
@Post(':id/executions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {contactId, context} = req.body;
@@ -311,10 +310,10 @@ export class Workflows {
* List executions for a workflow
*/
@Get(':id/executions')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -334,10 +333,10 @@ export class Workflows {
* Get a specific execution with details
*/
@Get(':id/executions/:executionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
@@ -355,10 +354,10 @@ export class Workflows {
* Cancel a workflow execution
*/
@Delete(':id/executions/:executionId')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
@@ -376,10 +375,10 @@ export class Workflows {
* Cancel all active executions for a workflow
*/
@Post(':id/executions/cancel-all')
@Middleware([requireAuth])
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
+2 -3
View File
@@ -11,6 +11,7 @@ export enum ErrorCode {
FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
@@ -73,9 +74,7 @@ export class NotFound extends HttpException {
* @param id Optional resource identifier to include in the message
*/
public constructor(resource: string, id?: string) {
const message = id
? `${resource} with ID "${id}" was not found`
: `That ${resource.toLowerCase()} was not found`;
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = {
@@ -1,6 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
status: EmailStatus.PENDING,
});
// Mock the email processor logic
// In a real implementation, you would:
// 1. Create job tester
// 2. Mock SES service
// 3. Process the job
// 4. Verify status changes
// Simulate processing
await prisma.email.update({
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
from: '[email protected]',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
attachments: toPrismaJson([
{
filename: 'document.pdf',
content: 'base64encodedcontent',
contentType: 'application/pdf',
},
] as unknown as Prisma.InputJsonValue,
]),
},
});
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
from: '[email protected]',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
},
include: {
project: true,
@@ -0,0 +1,281 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
* Verifies that CSV imports preserve subscription status correctly
*/
describe('Contact Import - Subscription Status Preservation', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Existing contacts', () => {
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
// Verify subscription status unchanged
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
// Verify subscription status unchanged (should NOT be re-subscribed)
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import with explicit subscribed=true
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: '[email protected]',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import with explicit subscribed=false
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
describe('New contacts', () => {
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
const newEmail = '[email protected]';
// Simulate import without subscribed column (undefined)
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
const newEmail = '[email protected]';
// Simulate import with explicit subscribed=true
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
expect(contact.subscribed).toBe(true);
});
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
const newEmail = '[email protected]';
// Simulate import with explicit subscribed=false
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
expect(contact.subscribed).toBe(false);
});
});
describe('CSV parsing logic', () => {
it('should parse "true" string as boolean true', () => {
const subscribedValue = 'true';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "1" string as boolean true', () => {
const subscribedValue = '1';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "yes" string as boolean true', () => {
const subscribedValue = 'yes';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "false" string as boolean false', () => {
const subscribedValue = 'false';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "0" string as boolean false', () => {
const subscribedValue = '0';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "no" string as boolean false', () => {
const subscribedValue = 'no';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should handle empty string as undefined', () => {
const subscribedValue = '';
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
it('should handle undefined as undefined', () => {
const subscribedValue = undefined;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
});
describe('Data preservation', () => {
it('should preserve existing contact data while updating subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
data: {
firstName: 'Original',
lastName: 'Name',
plan: 'pro',
},
});
// Update only subscription via import
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be updated
expect(updated?.subscribed).toBe(true);
// Original data should be preserved
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('Original');
expect(data?.lastName).toBe('Name');
expect(data?.plan).toBe('pro');
});
it('should merge new data while preserving subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: '[email protected]',
data: {
firstName: 'John',
plan: 'pro',
},
});
// Import new data without changing subscription
await ContactService.upsert(
projectId,
contact.email,
{
lastName: 'Doe',
company: 'Acme Inc',
},
undefined,
);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be unchanged
expect(updated?.subscribed).toBe(false);
// Data should be merged
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('John'); // Preserved
expect(data?.plan).toBe('pro'); // Preserved
expect(data?.lastName).toBe('Doe'); // New
expect(data?.company).toBe('Acme Inc'); // New
});
});
});
@@ -1,6 +1,6 @@
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => {
let projectId: string;
@@ -1,3 +1,4 @@
import type {ApiRequestCleanupJobData} from '@plunk/types';
import type {Job} from 'bullmq';
import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis';
@@ -5,7 +6,6 @@ import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
/**
* API Request Cleanup Worker
+115
View File
@@ -0,0 +1,115 @@
/**
* Background Job: Bulk Contact Action Processor
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import type {BulkContactActionJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
successCount: number;
failureCount: number;
errors: {contactId: string; email: string; error: string}[];
}
export function createBulkContactWorker() {
const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => {
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
successCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
try {
let batchResult: {updated?: number; deleted?: number};
switch (operation) {
case 'subscribe':
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'unsubscribe':
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'delete':
batchResult = await ContactService.bulkDelete(projectId, batchIds);
result.successCount += batchResult.deleted || 0;
break;
}
// If some contacts in batch weren't processed, track them as failures
const processedCount = batchResult.updated || batchResult.deleted || 0;
const failedCount = batchIds.length - processedCount;
if (failedCount > 0) {
result.failureCount += failedCount;
// Note: We don't have individual contact details for batch failures
}
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
email: '',
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
}
signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
return result;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
throw error;
}
},
{
connection: bulkContactQueue.opts.connection,
concurrency: 3, // Process max 3 bulk operations concurrently
},
);
worker.on('completed', job => {
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
});
return worker;
}
+8 -6
View File
@@ -3,10 +3,12 @@
* Processes campaign batches (queues emails for each contact in the batch)
*/
import type {CampaignBatchJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
import {campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>(
@@ -14,11 +16,11 @@ export function createCampaignWorker() {
async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
},
{
connection: campaignQueue.opts.connection,
@@ -27,15 +29,15 @@ export function createCampaignWorker() {
);
worker.on('completed', job => {
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
});
return worker;
@@ -3,10 +3,11 @@
* Processes domain verification jobs from the BullMQ queue
*/
import type {DomainVerificationJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
import {domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js';
+76 -3
View File
@@ -6,10 +6,14 @@
* Scheduled to run every 5 minutes via repeatable jobs
*/
import React from 'react';
import signale from 'signale';
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {MembershipService} from '../services/MembershipService.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
import {Keys} from '../services/keys.js';
@@ -26,7 +30,15 @@ export async function checkDomainVerifications() {
// Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({
select: {id: true, domain: true, projectId: true, verified: true},
select: {
id: true,
domain: true,
projectId: true,
verified: true,
project: {
select: {name: true},
},
},
skip: i,
take: 99,
});
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.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')) {
if (
error?.Code === 'Throttling' ||
error?.name === 'Throttling' ||
error?.message?.includes('Throttling')
) {
signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
signale.error(
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
);
throw e;
}
}
@@ -102,6 +120,31 @@ export async function checkDomainVerifications() {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
}
// Send email notification about domain verified
try {
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const members = await MembershipService.getMembers(dbDomain.projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainVerifiedEmail, {
projectName: dbDomain.project.name,
projectId: dbDomain.projectId,
domain: sesIdentity.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
);
await redis.setex(cacheKey, 604800, '1'); // 7 days
}
}
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`);
}
// Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
@@ -111,6 +154,36 @@ export async function checkDomainVerifications() {
if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
// Send email notification about domain verification failed
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const members = await MembershipService.getMembers(dbDomain.projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainUnverifiedEmail, {
projectName: dbDomain.project.name,
projectId: dbDomain.projectId,
domain: sesIdentity.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
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) {
signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`);
}
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
+96 -11
View File
@@ -3,18 +3,51 @@
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import type {SendEmailJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
import {sendRawEmail} from '../services/SESService.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {emailQueue} from '../services/QueueService.js';
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
export function createEmailWorker() {
/**
* Determine the email sending rate limit (emails per second)
* Priority: ENV variable > AWS SES quota > Safe default (14)
*/
async function getEmailRateLimit(): Promise<number> {
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
// If env variable is set, use it (override)
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
return EMAIL_RATE_LIMIT_PER_SECOND;
}
// Try to fetch from AWS SES
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
const quota = await getSendingQuota();
if (quota) {
signale.info(
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
);
return quota.maxSendRate;
}
// Fallback to safe default
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
return DEFAULT_RATE_LIMIT;
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
@@ -38,7 +71,7 @@ export function createEmailWorker() {
// Check if project is disabled
if (email.project.disabled) {
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
await prisma.email.update({
where: {id: emailId},
data: {
@@ -138,8 +171,60 @@ export function createEmailWorker() {
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
// If this email belongs to a campaign, check if all campaign emails have been sent
if (email.campaignId) {
const campaign = await prisma.campaign.findUnique({
where: {id: email.campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {
select: {name: true},
},
},
});
// Only check if campaign is still in SENDING status
if (campaign && campaign.status === CampaignStatus.SENDING) {
// Count how many emails have been sent for this campaign
const sentCount = await prisma.email.count({
where: {
campaignId: email.campaignId,
sentAt: {not: null},
},
});
// If all emails have been sent, mark campaign as SENT
if (sentCount >= campaign.totalRecipients) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
sentCount,
},
});
signale.success(
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
// Send notification about campaign send completed
const {NtfyService} = await import('../services/NtfyService.js');
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
campaign.totalRecipients,
);
}
}
}
} catch (error) {
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
@@ -157,22 +242,22 @@ export function createEmailWorker() {
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: 25, // Max 25 emails per second
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
},
},
);
worker.on('completed', job => {
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[EMAIL-PROCESSOR] Worker error:', err);
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
});
return worker;
+19 -15
View File
@@ -3,13 +3,15 @@
* Processes CSV contact imports with validation and batch processing
*/
import type {ContactImportJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {NtfyService} from '../services/NtfyService.js';
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
import {importQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
@@ -28,7 +30,7 @@ export function createImportWorker() {
async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data;
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
// Fetch project information for notifications
const project = await prisma.project.findUnique({
@@ -51,9 +53,9 @@ export function createImportWorker() {
// Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV
// Parse CSV with column header normalization
const records = parse(csvContent, {
columns: true, // Use first row as header
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
skip_empty_lines: true,
trim: true,
relax_column_count: true, // Allow rows with different column counts
@@ -66,15 +68,15 @@ export function createImportWorker() {
throw new Error('CSV file is empty');
}
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
// Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
// Validate that 'email' column exists
// Validate that 'email' column exists (case-insensitive)
const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
throw new Error('CSV must have an "email" column');
throw new Error('CSV must have an "email" column (case-insensitive)');
}
// Process contacts in batches
@@ -109,7 +111,7 @@ export function createImportWorker() {
continue;
}
// Extract subscribed field if present
// Extract subscribed field if present (case-insensitive)
const subscribedValue = record.subscribed;
let subscribed: boolean | undefined;
@@ -127,8 +129,10 @@ export function createImportWorker() {
const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact;
// Upsert contact with subscribed value from CSV if provided, otherwise default to true
await ContactService.upsert(projectId, email, data, subscribed ?? true);
// Upsert contact with subscribed value from CSV if provided
// For new contacts, ContactService.upsert defaults to true
// For existing contacts, only update if explicitly provided in CSV
await ContactService.upsert(projectId, email, data, subscribed);
result.successCount++;
if (isUpdate) {
@@ -151,7 +155,7 @@ export function createImportWorker() {
await job.updateProgress(progress);
}
console.log(
signale.info(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
);
@@ -168,7 +172,7 @@ export function createImportWorker() {
return result;
} catch (error) {
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
// Notify that import has failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -191,15 +195,15 @@ export function createImportWorker() {
);
worker.on('completed', job => {
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[IMPORT-PROCESSOR] Worker error:', err);
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
});
return worker;
+11 -9
View File
@@ -4,11 +4,13 @@
*/
import {CampaignStatus} from '@plunk/db';
import type {ScheduledCampaignJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
import {scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>(
@@ -16,7 +18,7 @@ export function createScheduledCampaignWorker() {
async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data;
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project
const campaign = await prisma.campaign.findUnique({
@@ -29,13 +31,13 @@ export function createScheduledCampaignWorker() {
});
if (!campaign) {
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return;
}
// Check if project is disabled
if (campaign.project.disabled) {
console.warn(
signale.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
);
await prisma.campaign.update({
@@ -47,7 +49,7 @@ export function createScheduledCampaignWorker() {
// Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) {
console.warn(
signale.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
);
return;
@@ -56,7 +58,7 @@ export function createScheduledCampaignWorker() {
// Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId);
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
},
{
connection: scheduledQueue.opts.connection,
@@ -65,15 +67,15 @@ export function createScheduledCampaignWorker() {
);
worker.on('completed', job => {
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
});
return worker;
+2 -1
View File
@@ -3,12 +3,13 @@
* Processes segment count update jobs from the BullMQ queue
*/
import type {SegmentCountJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {NtfyService} from '../services/NtfyService.js';
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
import {segmentCountQueue} from '../services/QueueService.js';
import {SegmentService} from '../services/SegmentService.js';
/**
+7 -1
View File
@@ -10,6 +10,7 @@ import {Worker} from 'bullmq';
import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createBulkContactWorker} from './bulk-contact-processor.js';
import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
@@ -25,7 +26,7 @@ async function startWorkers() {
try {
// Start email worker
const emailWorker = createEmailWorker();
const emailWorker = await createEmailWorker();
workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started');
@@ -49,6 +50,11 @@ async function startWorkers() {
workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started');
// Start bulk contact action worker
const bulkContactWorker = createBulkContactWorker();
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
signale.success('[WORKER] Bulk contact action worker started');
// Start segment count worker
const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker});
@@ -3,9 +3,11 @@
* Processes workflow steps from the queue (for delayed steps)
*/
import type {WorkflowStepJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
import {workflowQueue} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() {
@@ -33,15 +35,15 @@ export function createWorkflowWorker() {
);
worker.on('completed', job => {
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
});
return worker;
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
projectId = project.id;
userId = user.id;
// Mock request object
req = {
method: 'POST',
path: '/v1/send',
@@ -334,11 +333,6 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// TODO: Add assertion to verify request was NOT logged when disabled
// const loggedRequest = await prisma.apiRequest.findUnique({
// where: {id: 'test-request-id-123'},
// });
// expect(loggedRequest).toBeNull();
// Restore original value
if (originalEnv !== undefined) {
+97 -125
View File
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken';
import {JWT_SECRET} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
import type {AuthResponse} from '@plunk/types';
export interface AuthResponse {
type: 'jwt' | 'apiKey';
userId?: string;
projectId: string;
}
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
import {MembershipService} from '../services/MembershipService.js';
import {ProjectService} from '../services/ProjectService.js';
import {UserService} from '../services/UserService.js';
/**
* Middleware to check if this unsubscribe is authenticated on the dashboard
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
return id;
}
/**
* Middleware to require project access
* Validates that the user is authenticated and has access to the project specified in X-Project-Id header
* @param req
* @param res
* @param next
*/
export const requireProjectAccess = async (req: Request, res: Response, next: NextFunction) => {
try {
// First authenticate the user
const userId = parseJwt(req);
// Get project ID from header
const projectId = req.headers['x-project-id'] as string | undefined;
if (!projectId) {
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
}
// Verify user has access to this project and get project status
const [membership, project] = await Promise.all([
prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
}),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
]);
if (!membership) {
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
ErrorCode.PROJECT_DISABLED,
);
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require public API key authentication (for /v1/track endpoint only)
* Validates that the request has a valid public key and sets the project
@@ -174,12 +106,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by public key only
const project = await prisma.project.findFirst({
where: {
public: apiKey,
},
});
const project = await ProjectService.public(apiKey);
if (!project) {
throw new HttpException(
@@ -189,6 +121,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
};
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -203,12 +141,6 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
next();
} catch (error) {
next(error);
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by secret key only
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
const project = await ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
@@ -258,6 +190,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
};
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -272,12 +210,6 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
next();
} catch (error) {
next(error);
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by secret key only (public keys not allowed)
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
const project = await ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
@@ -325,6 +257,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
@@ -339,12 +277,6 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
return next();
}
@@ -360,24 +292,21 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
// Verify user has access to this project and get project status
const [membership, project] = await Promise.all([
prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
}),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
MembershipService.getMembership(userId, projectId),
ProjectService.id(projectId),
]);
if (!membership) {
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'jwt',
userId,
projectId,
};
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
@@ -392,12 +321,55 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
}
// Set auth response with project ID
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require email verification
* Must be used AFTER isAuthenticated or requireProjectAccess
* @param req
* @param res
* @param next
*/
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = res.locals.auth;
if (auth.type === 'apiKey') {
return next();
}
if (!auth.userId) {
throw new NotAuthenticated();
}
const user = await UserService.id(auth.userId);
if (!user) {
throw new NotAuthenticated();
}
// If platform email verification is disabled, skip check
if (!PLUNK_ENABLED) {
return next();
}
// OAuth users are always considered verified
if (user.type !== 'PASSWORD') {
return next();
}
// PASSWORD users must verify email
if (!user.emailVerified) {
throw new HttpException(
403,
'Please verify your email address to access this resource',
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
);
}
next();
} catch (error) {
+16 -58
View File
@@ -1,58 +1,11 @@
import type {Prisma} from '@plunk/db';
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
import {ActivityType} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
/**
* Activity types that can be tracked
*/
export enum ActivityType {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
/**
* Unified activity item
*/
export interface Activity {
id: string;
type: ActivityType;
timestamp: Date;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
/**
* Paginated activity response
*/
export interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
/**
* Activity stats for dashboard
*/
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
import {Keys} from './keys.js';
/**
* Activity Service
@@ -105,7 +58,7 @@ export class ActivityService {
contactId?: string,
startDate?: Date,
endDate?: Date,
): Promise<PaginatedActivities> {
): Promise<CursorPaginatedResponse<Activity>> {
// Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
@@ -156,8 +109,8 @@ export class ActivityService {
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return {
activities: results,
nextCursor,
data: results,
cursor: nextCursor,
hasMore,
};
}
@@ -170,7 +123,7 @@ export class ActivityService {
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
try {
const cached = await redis.get(cacheKey);
@@ -178,7 +131,7 @@ export class ActivityService {
return JSON.parse(cached);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
}
// Default date range to last 30 days if not specified
@@ -241,7 +194,7 @@ export class ActivityService {
try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) {
console.warn('[ACTIVITY] Failed to cache stats:', error);
signale.warn('[ACTIVITY] Failed to cache stats:', error);
}
return stats;
@@ -261,7 +214,7 @@ export class ActivityService {
await redis.del(...keys);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
}
}
@@ -452,6 +405,11 @@ export class ActivityService {
for (const email of emails) {
const baseMetadata = {
subject: email.subject,
body: email.body,
from: email.from,
fromName: email.fromName,
replyTo: email.replyTo,
toName: email.toName,
sourceType: email.sourceType,
campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name,
+17 -5
View File
@@ -1,5 +1,6 @@
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Time series data point for analytics
@@ -57,14 +58,16 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = `analytics:timeseries:${projectId}:${limitedStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.timeseries(
projectId,
limitedStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Raw SQL query for efficient daily aggregation
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
const result = await prisma.$queryRaw<
{
date: Date;
@@ -190,7 +193,11 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:campaignStats:${projectId}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.campaignStats(
projectId,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -294,7 +301,12 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = `analytics:topEvents:${projectId}:${limit}:${effectiveStartDate.toISOString()}:${effectiveEndDate.toISOString()}`;
const cacheKey = Keys.Analytics.topEvents(
projectId,
limit,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
+206 -38
View File
@@ -1,44 +1,17 @@
import {EmailSourceType} from '@plunk/db';
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
/**
* Usage information for a specific email category
*/
export interface CategoryUsage {
limit: number | null; // null = unlimited
usage: number;
percentage: number; // 0-100
isWarning: boolean; // true if >= 80%
isBlocked: boolean; // true if >= 100%
}
/**
* Complete billing limits and usage for a project
*/
export interface BillingLimitsResponse {
workflows: CategoryUsage;
campaigns: CategoryUsage;
transactional: CategoryUsage;
currency: string | null;
}
/**
* Result of limit check
*/
export interface LimitCheckResult {
allowed: boolean;
warning: boolean; // true if >= 80% but < 100%
usage: number;
limit: number | null;
percentage: number;
message?: string;
}
/**
* Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category
@@ -221,7 +194,8 @@ export class BillingLimitService {
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
if (!project.subscription && !hasCustomLimits) {
// Only enforce free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = await this.getTotalUsage(projectId);
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / freeLimit) * 100;
@@ -236,6 +210,9 @@ export class BillingLimitService {
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
return {
allowed: false,
warning: false,
@@ -257,6 +234,16 @@ export class BillingLimitService {
percentage,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification (only once per month)
await this.sendWarningEmail(
projectId,
project.name,
totalUsage,
freeLimit,
percentage,
'Free Tier (All Types)',
);
}
return {
@@ -297,6 +284,9 @@ export class BillingLimitService {
if (project) {
// Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
}
return {
@@ -320,7 +310,17 @@ export class BillingLimitService {
});
if (project) {
await NtfyService.notifyBillingLimitApproaching(project.name, projectId, usage, limit, percentage, sourceType);
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
usage,
limit,
percentage,
sourceType,
);
// Send email notification (only once per month)
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
}
}
@@ -415,7 +415,8 @@ export class BillingLimitService {
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
if (!project.subscription && !hasCustomLimits) {
// Only show free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
const limit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / limit) * 100;
@@ -455,7 +456,11 @@ export class BillingLimitService {
/**
* Invalidate usage cache for a project
* Call this when billing period resets or limits are changed
* Primarily used in tests to reset cache state between scenarios.
* In production, the cache naturally expires after 5 minutes.
*
* NOTE: Updating billing limits does NOT require clearing usage cache
* (use clearNotificationCacheForChangedLimits instead)
*
* @param projectId - Project ID
*/
@@ -474,6 +479,69 @@ export class BillingLimitService {
}
}
/**
* Clear notification cache keys for billing limits that have changed
* This allows new warning/limit emails to be sent when updated limits are reached
*
* @param projectId - Project ID
* @param oldLimits - Previous billing limits
* @param newLimits - New billing limits
*/
public static async clearNotificationCacheForChangedLimits(
projectId: string,
oldLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
newLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
): Promise<void> {
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const keysToDelete: string[] = [];
// Check workflows limit
if (oldLimits.workflows !== newLimits.workflows) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
);
}
// Check campaigns limit
if (oldLimits.campaigns !== newLimits.campaigns) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
);
}
// Check transactional limit
if (oldLimits.transactional !== newLimits.transactional) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
);
}
if (keysToDelete.length > 0) {
await Promise.all(keysToDelete.map(key => redis.del(key)));
signale.debug(
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
}
}
/**
* Get Redis cache key for usage count
*/
@@ -481,7 +549,7 @@ export class BillingLimitService {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
return Keys.Billing.usage(projectId, sourceType, year, month);
}
/**
@@ -493,4 +561,104 @@ export class BillingLimitService {
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return {start, end};
}
/**
* Send billing limit warning email to project members
*/
private static async sendWarningEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitWarningEmail, {
projectName,
projectId,
usage,
limit,
percentage,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
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) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
}
}
/**
* Send billing limit exceeded email to project members
*/
private static async sendLimitExceededEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitExceededEmail, {
projectName,
projectId,
usage,
limit,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
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) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
}
}
}
+69 -73
View File
@@ -1,47 +1,24 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI} from '../app/constants.js';
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
export interface CreateCampaignData {
name: string;
description?: string;
subject: string;
body: string;
from: string;
fromName?: string;
replyTo?: string;
audienceType: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
}
export interface UpdateCampaignData {
name?: string;
description?: string;
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
audienceType?: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
}
export class CampaignService {
/**
* Create a new campaign
@@ -71,7 +48,7 @@ export class CampaignService {
SegmentService.validateCondition(data.audienceCondition);
}
// Create campaign
// Create campaign with initial recipient count of 0
const campaign = await prisma.campaign.create({
data: {
projectId,
@@ -83,9 +60,10 @@ export class CampaignService {
fromName: data.fromName,
replyTo: data.replyTo,
audienceType: data.audienceType,
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
},
include: {
project: {
@@ -94,10 +72,17 @@ export class CampaignService {
},
});
// Calculate and update recipient count for the draft
const recipientCount = await this.getRecipientCount(projectId, campaign);
const updatedCampaign = await prisma.campaign.update({
where: {id: campaign.id},
data: {totalRecipients: recipientCount},
});
// Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return campaign;
return updatedCampaign;
}
/**
@@ -123,7 +108,7 @@ export class CampaignService {
if (data.audienceCondition) {
SegmentService.validateCondition(data.audienceCondition);
}
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
}
if (data.segmentId !== undefined) {
@@ -147,10 +132,25 @@ export class CampaignService {
delete (updateData as Record<string, unknown>).segmentId;
}
return prisma.campaign.update({
// Update the campaign first
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: updateData,
});
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
const audienceChanged =
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
return prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: recipientCount},
});
}
return updatedCampaign;
}
/**
@@ -184,7 +184,7 @@ export class CampaignService {
page?: number;
pageSize?: number;
} = {},
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
@@ -207,7 +207,7 @@ export class CampaignService {
]);
return {
campaigns,
data: campaigns,
total,
page,
pageSize,
@@ -252,7 +252,7 @@ export class CampaignService {
const campaign = await this.get(projectId, campaignId);
// Create a new campaign with the same data but reset status and stats
return prisma.campaign.create({
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId,
name: `${campaign.name} (Copy)`,
@@ -266,7 +266,7 @@ export class CampaignService {
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0,
totalRecipients: 0, // Will be updated below
sentCount: 0,
deliveredCount: 0,
openedCount: 0,
@@ -274,6 +274,13 @@ export class CampaignService {
bouncedCount: 0,
},
});
// Calculate and update recipient count
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
return prisma.campaign.update({
where: {id: duplicatedCampaign.id},
data: {totalRecipients: recipientCount},
});
}
/**
@@ -294,6 +301,24 @@ export class CampaignService {
throw new HttpException(400, 'Campaign has no recipients');
}
// Check billing limits before scheduling/sending the campaign
// This ensures users cannot schedule campaigns that would exceed their quota
if (STRIPE_ENABLED) {
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
// If there's a limit set, verify the campaign won't exceed it
if (limitCheck.limit !== null) {
const projectedUsage = limitCheck.usage + recipientCount;
if (projectedUsage > limitCheck.limit) {
throw new HttpException(
403,
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
);
}
}
}
if (scheduledFor) {
// Schedule for later
if (scheduledFor.getTime() <= Date.now()) {
@@ -412,7 +437,7 @@ export class CampaignService {
}
if (campaign.status !== CampaignStatus.SENDING) {
console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
return;
}
@@ -426,6 +451,7 @@ export class CampaignService {
const contactData =
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
const variables = {
id: contact.id,
email: contact.email,
...contactData,
data: contactData,
@@ -458,21 +484,11 @@ export class CampaignService {
replyTo: campaign.replyTo || undefined,
});
} catch (error) {
console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
// Continue with other contacts even if one fails
}
}
// Update sent count
await prisma.campaign.update({
where: {id: campaignId},
data: {
sentCount: {
increment: contacts.length,
},
},
});
// Queue next batch if there are more contacts
if (hasMore && nextCursor) {
await QueueService.queueCampaignBatch({
@@ -482,27 +498,6 @@ export class CampaignService {
limit,
cursor: nextCursor,
});
} else {
// All batches processed, mark campaign as SENT
const completedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENT,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign send completed
await NtfyService.notifyCampaignSendCompleted(
completedCampaign.name,
completedCampaign.project.name,
completedCampaign.projectId,
completedCampaign.totalRecipients || 0,
);
}
}
@@ -570,6 +565,7 @@ export class CampaignService {
await prisma.campaign.update({
where: {id: campaignId},
data: {
sentCount: sentEmails,
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
@@ -728,7 +724,7 @@ export class CampaignService {
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: {
const condition = campaign.audienceCondition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
@@ -764,7 +760,7 @@ export class CampaignService {
throw new HttpException(404, 'Segment not found');
}
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
+193 -16
View File
@@ -1,17 +1,12 @@
import {type Contact, Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {isValidLanguageCode} from '@plunk/shared';
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export class ContactService {
/**
* Get all contacts for a project with cursor-based pagination
@@ -22,7 +17,7 @@ export class ContactService {
limit = 20,
cursor?: string,
search?: string,
): Promise<PaginatedContacts> {
): Promise<CursorPaginatedResponse<Contact>> {
const where: Prisma.ContactWhereInput = {
projectId,
...(search
@@ -57,7 +52,7 @@ export class ContactService {
const total = !cursor ? await prisma.contact.count({where}) : 0;
return {
contacts: results,
data: results,
total,
cursor: nextCursor,
hasMore,
@@ -224,11 +219,32 @@ export class ContactService {
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved fields
if (key === 'plunk_id' || key === 'plunk_email') {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = [
'plunk_id',
'plunk_email',
'id',
'email',
'unsubscribeUrl',
'subscribeUrl',
'manageUrl',
];
if (reservedFields.includes(key)) {
continue;
}
// Validate locale field (special user-settable field)
if (key === 'locale') {
if (typeof value === 'string') {
if (!isValidLanguageCode(value)) {
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
}
} else if (value !== null && value !== undefined) {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
@@ -255,7 +271,7 @@ export class ContactService {
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
@@ -275,7 +291,7 @@ export class ContactService {
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? true,
},
});
@@ -297,11 +313,18 @@ export class ContactService {
Object.assign(mergedData, contact.data);
}
// Explicitly expose locale as a predefined field (available in templates)
// This ensures locale is always accessible even if not in contact.data
if (mergedData.locale === undefined) {
mergedData.locale = null;
}
// Add temporary (non-persistent) data
if (temporaryData) {
for (const [key, value] of Object.entries(temporaryData)) {
// Skip reserved fields
if (key === 'plunk_id' || key === 'plunk_email') {
// Skip reserved system-generated fields
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
@@ -339,6 +362,25 @@ export class ContactService {
return contact;
}
/**
* Get project by contact ID
* Used to fetch project settings for public endpoints
*/
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
select: {
project: {
select: {
language: true,
},
},
},
});
return contact?.project || null;
}
/**
* PUBLIC: Subscribe a contact
*/
@@ -635,6 +677,117 @@ export class ContactService {
return {deletedFrom: result};
}
/**
* Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches
*/
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
// Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
if (unsubscribedIds.length === 0) {
return {updated: 0};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk subscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
if (subscribedIds.length === 0) {
return {updated: 0};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk delete contacts
*/
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({
where: {
id: {in: contactIds},
projectId,
},
});
return {deleted: result.count};
}
/**
* Helper: Check if a field is used in a filter condition (recursive)
*/
@@ -679,4 +832,28 @@ export class ContactService {
return false;
}
/**
* Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling
*
* @private
*/
private static async trackEventsSequentially(
projectId: string,
eventName: string,
contactIds: string[],
): Promise<void> {
for (const contactId of contactIds) {
try {
await EventService.trackEvent(projectId, eventName, contactId);
} catch (error) {
// Log error but continue processing remaining events
// Suppress logging in test environments to reduce noise from cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
}
}
}
}
}
+66 -2
View File
@@ -1,7 +1,12 @@
import React from 'react';
import signale from 'signale';
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {redis, wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
@@ -81,6 +86,31 @@ export class DomainService {
// Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
// Send email notification about domain verified
try {
// Check deduplication cache
const cacheKey = Keys.Domain.verifiedEmail(domainId);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainVerifiedEmail, {
projectName: updatedDomain.project.name,
projectId: updatedDomain.project.id,
domain: domain.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
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) {
signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError);
}
} else if (attributes.status !== 'Success' && domain.verified) {
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
@@ -93,7 +123,41 @@ export class DomainService {
});
// Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
await NtfyService.notifyDomainVerificationFailed(
domain.domain,
updatedDomain.project.name,
updatedDomain.project.id,
);
// Send email notification about domain verification failed
try {
// Check deduplication cache (monthly)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainUnverifiedEmail, {
projectName: updatedDomain.project.name,
projectId: updatedDomain.project.id,
domain: domain.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
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) {
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
}
}
return {
+72 -15
View File
@@ -1,11 +1,12 @@
import type {Contact, Email, Prisma, Project} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {renderTemplate} from '@plunk/shared';
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
@@ -91,8 +92,8 @@ export class EmailService {
fromName: params.fromName,
toName: params.toName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType: EmailSourceType.TRANSACTIONAL,
templateId: params.templateId,
status: EmailStatus.PENDING,
@@ -152,8 +153,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
campaignId: params.campaignId,
@@ -213,8 +214,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
@@ -250,8 +251,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
@@ -332,6 +333,7 @@ export class EmailService {
subject: email.subject,
body: email.body,
data: {
id: email.contact.id,
email: email.contact.email,
...contactData,
data: contactData,
@@ -407,7 +409,7 @@ export class EmailService {
sentAt: new Date().toISOString(),
});
} catch (error) {
console.error(`[EMAIL] Failed to send email ${emailId}:`, error);
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
@@ -502,6 +504,42 @@ export class EmailService {
data: updateData,
});
// Update campaign stats if applicable
if (email.campaignId) {
const campaignUpdate: Prisma.CampaignUpdateInput = {};
switch (eventType) {
case 'delivered':
campaignUpdate.deliveredCount = {increment: 1};
break;
case 'opened':
// Only increment unique opens to match getStats logic
if (!email.openedAt) {
campaignUpdate.openedCount = {increment: 1};
}
break;
case 'clicked':
// Only increment unique clicks to match getStats logic
if (!email.clickedAt) {
campaignUpdate.clickedCount = {increment: 1};
}
break;
case 'bounced':
campaignUpdate.bouncedCount = {increment: 1};
break;
}
if (Object.keys(campaignUpdate).length > 0) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: campaignUpdate,
});
}
}
// Track event
await prisma.event.create({
data: {
@@ -509,7 +547,7 @@ export class EmailService {
contactId: email.contactId,
emailId: email.id,
name: `email.${eventType}`,
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
data: metadata ? toPrismaJson(metadata) : undefined,
},
});
}
@@ -603,19 +641,38 @@ export class EmailService {
let html = content;
const unsubscribeHtml = includeUnsubscribe
? `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
? (() => {
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
// Get translator for contact's locale or project's language
const translator = createTranslatorSync(contactLocale || project.language || 'en');
const unsubscribeText = translator.t('email.footer.unsubscribeText', {
projectName: project.name,
});
const updatePreferencesText = translator.t('email.footer.updatePreferences');
return `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
<tbody>
<tr>
<td>
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">update your preferences</a>.
${unsubscribeText}
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
</p>
</td>
</tr>
</tbody>
</table>`
</table>`;
})()
: '';
// Add Plunk badge if billing is enabled and project has no subscription (free tier)
@@ -0,0 +1,190 @@
import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker';
import type {EmailVerificationResult} from '@plunk/types';
import {redis} from '../database/redis.js';
const DISPOSABLE_DOMAINS_URL =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
// Known email forwarding/alias services
const FORWARDING_DOMAINS = new Set([
'privaterelay.appleid.com', // Apple Sign In
'mozmail.com', // Firefox Relay
'simplelogin.com', // SimpleLogin
'simplelogin.fr',
'simplelogin.co',
'simplelogin.io',
'aleeas.com',
'slmail.me',
'dralias.com',
'8shield.net',
'anonaddy.com', // Addy.io
'anonaddy.me',
'addy.io',
'duck.com', // DuckDuckGo
'33mail.com', // 33mail
'33m.co',
'passmail.com', // Proton Pass
'passmail.net',
'passinbox.com',
'passfwd.com',
'y.yo.fr',
'opayq.com', // IronVest (formerly Blur)
'cloak.id', // Cloaked
'erine.email', // Erine
'use.startmail.com', // StartMail
]);
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null;
/**
* Verify an email address
* - Checks if domain exists (DNS A/AAAA records)
* - Checks for MX records
* - Detects disposable email addresses
* - Detects forwarding/alias email addresses
* - Suggests corrections for common typos
*/
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
const result: EmailVerificationResult = {
email,
valid: true,
isDisposable: false,
isAlias: false,
isTypo: false,
isPlusAddressed: false,
domainExists: false,
hasMxRecords: false,
reasons: [],
};
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
result.valid = false;
result.reasons.push('Invalid email format');
return result;
}
const domain = emailParts[1]!; // Safe to assert, we already validated length
// Check if email is from a disposable domain using GitHub list
result.isDisposable = await this.isDisposableDomain(domain);
// Check if email is from a known forwarding/alias service
result.isAlias = this.isForwardingDomain(domain);
// Check for plus addressing
result.isPlusAddressed = emailParts[0]!.includes('+');
// Check for common typos and suggest corrections
const typoCheck = run({email});
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
result.suggestedEmail = typoCheck.full;
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
result.isTypo = true;
}
// Check if domain exists (has any DNS records)
try {
await dns.resolve(domain, 'A');
result.domainExists = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.domainExists = true;
} catch {
result.domainExists = false;
result.valid = false;
result.reasons.push('Domain does not exist');
}
}
// Check MX records (only if domain exists)
if (result.domainExists) {
try {
const mxRecords = await dns.resolveMx(domain);
result.hasMxRecords = mxRecords && mxRecords.length > 0;
if (!result.hasMxRecords) {
result.valid = false;
result.reasons.push('No MX records found for domain');
}
} catch {
result.hasMxRecords = false;
result.valid = false;
result.reasons.push('No MX records found for domain');
}
}
// If no issues were found, add a success reason
if (result.valid && result.reasons.length === 0) {
result.reasons.push('Email appears to be valid');
}
return result;
}
/**
* Fetch and cache the disposable domains list from GitHub
* Uses Redis for caching with 24-hour TTL
* Falls back to in-memory cache if Redis fails
*/
private static async getDisposableDomains(): Promise<Set<string>> {
// Return in-memory cache if available
if (this.disposableDomainsSet) {
return this.disposableDomainsSet;
}
try {
// Try to get from Redis cache first
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
if (cached) {
const domains = JSON.parse(cached) as string[];
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
}
// Fetch from GitHub if not in cache
const response = await fetch(DISPOSABLE_DOMAINS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
} catch (error) {
console.error('Error fetching disposable domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
const disposableDomains = await this.getDisposableDomains();
return disposableDomains.has(domain.toLowerCase());
}
/**
* Check if a domain is a known forwarding/alias service
*/
private static isForwardingDomain(domain: string): boolean {
return FORWARDING_DOMAINS.has(domain.toLowerCase());
}
}
+48 -15
View File
@@ -1,9 +1,12 @@
import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
@@ -30,7 +33,7 @@ export class EventService {
contactId,
emailId,
name: eventName,
data: data ? (data as Prisma.InputJsonValue) : undefined,
data: data ? toPrismaJson(data) : undefined,
},
});
@@ -48,11 +51,11 @@ export class EventService {
* Should be called when workflows are enabled/disabled or updated
*/
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
try {
await redis.del(cacheKey);
} catch (error) {
console.warn('[EVENT] Failed to invalidate workflow cache:', error);
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
}
}
@@ -287,9 +290,9 @@ export class EventService {
* @param eventName - The event name to delete
*/
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of system events
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
throw new Error('Cannot delete system events (email.* or segment.*)');
// Prevent deletion of reserved system events
if (this.isReservedEvent(eventName)) {
throw new Error(`Cannot delete reserved system event: ${eventName}`);
}
// Check if event is in use
@@ -311,6 +314,36 @@ export class EventService {
return {deletedCount: result.count};
}
/**
* Check if an event name is reserved for system use
* Reserved patterns:
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
* - contact.subscribed, contact.unsubscribed
* - segment.*.entry, segment.*.exit
*
* @param eventName - The event name to check
* @returns true if the event is reserved, false otherwise
*/
public static isReservedEvent(eventName: string): boolean {
// Email events: email.*
if (eventName.startsWith('email.')) {
return true;
}
// Contact events: contact.subscribed, contact.unsubscribed
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
return true;
}
// Segment events: segment.*.entry, segment.*.exit
// Pattern: segment.<slug>.entry or segment.<slug>.exit
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
return true;
}
return false;
}
/**
* Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance
@@ -322,7 +355,7 @@ export class EventService {
data?: Record<string, unknown>,
): Promise<void> {
// Try to get workflows from cache
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
let workflows;
try {
@@ -331,7 +364,7 @@ export class EventService {
workflows = JSON.parse(cached);
}
} catch (error) {
console.warn('[EVENT] Failed to get workflows from cache:', error);
signale.warn('[EVENT] Failed to get workflows from cache:', error);
}
// If not in cache, fetch from database
@@ -353,7 +386,7 @@ export class EventService {
try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) {
console.warn('[EVENT] Failed to cache workflows:', error);
signale.warn('[EVENT] Failed to cache workflows:', error);
}
}
@@ -368,7 +401,7 @@ export class EventService {
} else {
// If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip
console.log(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
}
}
}
@@ -394,7 +427,7 @@ export class EventService {
});
if (!workflow || workflow.steps.length === 0) {
console.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
return;
}
@@ -429,7 +462,7 @@ export class EventService {
const triggerStep = workflow.steps[0];
if (!triggerStep) {
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return;
}
@@ -440,18 +473,18 @@ export class EventService {
contactId,
status: 'RUNNING',
currentStepId: triggerStep.id,
context: context ? (context as Prisma.InputJsonValue) : undefined,
context: context ? toPrismaJson(context) : undefined,
},
});
console.log(
signale.info(
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
);
// Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) {
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
}
}
+368
View File
@@ -0,0 +1,368 @@
import type {Membership} from '@plunk/db';
import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
/**
* Service for managing project memberships
* Centralizes all membership-related database queries with caching
*/
export class MembershipService {
// ============================================
// AUTHORIZATION METHODS (Cached)
// ============================================
/**
* Check if user has any access to a project (any role)
* CACHED (1 min TTL) - called on every authenticated request
*/
public static async hasAccess(userId: string, projectId: string): Promise<boolean> {
return wrapRedis(
Keys.Membership.access(userId, projectId),
async () => {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
return membership !== null;
},
REDIS_ONE_MINUTE,
);
}
/**
* Check if user has admin or owner access to a project
* CACHED (1 min TTL) - called before write operations
*/
public static async hasAdminAccess(userId: string, projectId: string): Promise<boolean> {
return wrapRedis(
Keys.Membership.admin(userId, projectId),
async () => {
const membership = await prisma.membership.findFirst({
where: {
userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
return membership !== null;
},
REDIS_ONE_MINUTE,
);
}
/**
* Get user's membership with role info
* CACHED (1 min TTL) - returns full membership or null
*/
public static async getMembership(userId: string, projectId: string): Promise<Membership | null> {
return wrapRedis(
Keys.Membership.full(userId, projectId),
async () => {
return prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
},
REDIS_ONE_MINUTE,
);
}
/**
* Require membership or throw 404
* Uses cached getMembership internally
*/
public static async requireAccess(userId: string, projectId: string): Promise<Membership> {
const membership = await this.getMembership(userId, projectId);
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
return membership;
}
/**
* Require admin/owner access or throw 403
* Uses cached hasAdminAccess internally
*/
public static async requireAdminAccess(userId: string, projectId: string): Promise<Membership> {
const membership = await this.getMembership(userId, projectId);
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
if (membership.role !== 'ADMIN' && membership.role !== 'OWNER') {
throw new HttpException(403, 'Insufficient permissions. Admin or owner access required.');
}
return membership;
}
// ============================================
// MEMBER LISTING (Not Cached - Dynamic Data)
// ============================================
/**
* Get all members of a project with user info
* NOT CACHED - returns fresh data for member management UI
*/
public static async getMembers(projectId: string): Promise<MemberWithEmail[]> {
const memberships = await prisma.membership.findMany({
where: {
projectId,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
});
return memberships.map(m => ({
userId: m.userId,
email: m.user.email,
role: m.role,
createdAt: m.createdAt,
}));
}
/**
* Get project owner
* CACHED (5 min TTL) - owner rarely changes
*/
public static async getOwner(projectId: string): Promise<OwnerInfo> {
return wrapRedis(
Keys.Membership.owner(projectId),
async () => {
const ownerMembership = await prisma.membership.findFirst({
where: {
projectId,
role: 'OWNER',
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!ownerMembership) {
throw new HttpException(404, 'Project owner not found');
}
return {
userId: ownerMembership.userId,
email: ownerMembership.user.email,
};
},
FIVE_MINUTES_IN_SECONDS,
);
}
// ============================================
// CRUD OPERATIONS (Invalidate Cache)
// ============================================
/**
* Add a member to a project
* Invalidates cache for the project
*/
public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// Check if membership already exists
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (existingMembership) {
throw new HttpException(409, 'User is already a member of this project');
}
// Create new membership
const newMembership = await prisma.membership.create({
data: {
userId,
projectId,
role,
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
return newMembership;
}
/**
* Update a member's role
* Throws if trying to change OWNER role
* Invalidates cache
*/
public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// Get existing membership
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (!existingMembership) {
throw new HttpException(404, 'Membership not found');
}
// Prevent changing OWNER role
if (existingMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot change the role of the project owner');
}
// Update role
const updatedMembership = await prisma.membership.update({
where: {
userId_projectId: {
userId,
projectId,
},
},
data: {
role: newRole,
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
return updatedMembership;
}
/**
* Remove a member from a project
* Throws if trying to remove OWNER
* Invalidates cache
*/
public static async removeMember(projectId: string, userId: string): Promise<void> {
// Get existing membership
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (!existingMembership) {
throw new HttpException(404, 'Membership not found');
}
// Prevent removing OWNER
if (existingMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot remove the project owner');
}
// Delete membership
await prisma.membership.delete({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
}
// ============================================
// UTILITY METHODS
// ============================================
/**
* Check if user is member of any disabled project
* NOT CACHED - security-critical check
*/
public static async userHasDisabledProject(userId: string): Promise<DisabledProjectInfo> {
const disabledMemberships = await prisma.membership.findMany({
where: {
userId,
project: {
disabled: true,
},
},
include: {
project: {
select: {
name: true,
},
},
},
});
return {
hasDisabledProject: disabledMemberships.length > 0,
disabledProjectNames: disabledMemberships.map(m => m.project.name),
};
}
// ============================================
// PRIVATE CACHE MANAGEMENT
// ============================================
/**
* Invalidate all caches for a project and user
* Called after membership changes
*/
private static async invalidateCache(projectId: string, userId?: string): Promise<void> {
const keysToDelete: string[] = [];
if (userId) {
// Invalidate user-specific caches
keysToDelete.push(
Keys.Membership.access(userId, projectId),
Keys.Membership.admin(userId, projectId),
Keys.Membership.full(userId, projectId),
);
}
// Invalidate project-wide caches
keysToDelete.push(Keys.Membership.owner(projectId));
// Delete all keys
if (keysToDelete.length > 0) {
await redis.del(...keysToDelete);
}
}
}
+53 -35
View File
@@ -1,40 +1,6 @@
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
import signale from 'signale';
/**
* Priority levels for ntfy notifications
* Based on ntfy.sh documentation
*/
export enum NtfyPriority {
MIN = 1, // No vibration/sound, relegated to "Other notifications"
LOW = 2, // No vibration/sound, hidden until drawer opened
DEFAULT = 3, // Short vibration and sound (standard)
HIGH = 4, // Long vibration, pop-over notification
MAX = 5, // Long vibration bursts, pop-over notification
}
/**
* Tags for ntfy notifications (emoji shortcuts)
*/
export enum NtfyTag {
WARNING = 'warning',
ERROR = 'rotating_light',
SUCCESS = 'white_check_mark',
MONEY = 'money_with_wings',
SHIELD = 'shield',
ROCKET = 'rocket',
BELL = 'bell',
CHART = 'chart_with_upwards_trend',
SKULL = 'skull',
INFO = 'information_source',
}
export interface NtfyNotification {
title: string;
message: string;
priority?: NtfyPriority;
tags?: NtfyTag[];
}
/**
* Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable
@@ -241,12 +207,25 @@ export class NtfyService {
* Notify about security warning (non-critical)
*/
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:security:warning:${projectId}`;
const exists = await redis.exists(cacheKey);
if (exists) {
return;
}
const warningText = warnings.join(', ');
await this.sendDefault(
'Security Warning',
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
// Throttle for 1 hour
await redis.setex(cacheKey, 3600, '1');
}
/**
@@ -352,6 +331,19 @@ export class NtfyService {
});
}
/**
* Notify about failed signup attempt with invalid email - LOW priority
*/
public static async notifyFailedSignupAttempt(email: string, reasons: string[]): Promise<void> {
const reasonText = reasons.join(', ');
await this.send({
title: 'Failed Signup - Invalid Email',
message: `Signup attempt blocked for email: ${email}\nReasons: ${reasonText}`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING, NtfyTag.SHIELD],
});
}
/**
* Notify about new user account created via OAuth - LOW priority
*/
@@ -610,11 +602,24 @@ export class NtfyService {
percentage: number,
sourceType: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
const exists = await redis.exists(cacheKey);
if (exists) {
return;
}
await this.sendDefault(
'Billing Limit Warning',
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
);
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
}
// ===== Billing and usage limit notifications =====
@@ -629,11 +634,24 @@ export class NtfyService {
limit: number,
sourceType: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
const exists = await redis.exists(cacheKey);
if (exists) {
return;
}
await this.sendUrgent(
'Billing Limit Exceeded',
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
);
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
}
/**
+31
View File
@@ -0,0 +1,31 @@
import {Keys} from './keys.js';
import {wrapRedis} from '../database/redis.js';
import {prisma} from '../database/prisma.js';
export class ProjectService {
public static async id(id: string) {
return wrapRedis(Keys.Project.id(id), async () => {
return prisma.project.findUnique({where: {id}});
});
}
public static async secret(key: string) {
return wrapRedis(Keys.Project.secret(key), async () => {
return prisma.project.findUnique({
where: {
secret: key,
},
});
});
}
public static async public(key: string) {
return wrapRedis(Keys.Project.public(key), async () => {
return prisma.project.findUnique({
where: {
public: key,
},
});
});
}
}
+74 -47
View File
@@ -1,57 +1,21 @@
import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import type {
ApiRequestCleanupJobData,
BulkContactActionJobData,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
ScheduledCampaignJobData,
SegmentCountJobData,
SendEmailJobData,
WorkflowStepJobData,
} from '@plunk/types';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
/**
* Queue Job Data Types
*/
export interface SendEmailJobData {
emailId: string;
}
export interface CampaignBatchJobData {
campaignId: string;
batchNumber: number;
offset: number;
limit: number;
cursor?: string; // For cursor-based pagination
}
export interface WorkflowStepJobData {
executionId: string;
stepId: string;
type?: 'process-step' | 'timeout'; // Job type for different handling
stepExecutionId?: string; // For timeout jobs, reference to the step execution
}
export interface ScheduledCampaignJobData {
campaignId: string;
}
export interface ContactImportJobData {
projectId: string;
csvData: string; // Base64 encoded CSV content
filename: string;
}
export interface SegmentCountJobData {
projectId?: string; // Optional: if provided, only update this project's segments
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DomainVerificationJobData {
// Empty for now - processes all domains
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ApiRequestCleanupJobData {
// Empty - cleans up old API request logs
}
/**
* Queue Configuration
*/
@@ -181,6 +145,19 @@ export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-r
},
});
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for bulk operations
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed bulk operations
removeOnFail: 100, // Keep last 100 failed bulk operations
},
});
/**
* Queue Service - Centralized queue management
*/
@@ -328,6 +305,48 @@ export class QueueService {
};
}
/**
* Queue bulk contact action job
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
);
}
/**
* Get bulk action job status and progress
*/
public static async getBulkActionJobStatus(jobId: string) {
const job = await bulkContactQueue.getJob(jobId);
if (!job) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue segment count update job
*/
@@ -354,6 +373,7 @@ export class QueueService {
segmentCountCounts,
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
@@ -363,6 +383,7 @@ export class QueueService {
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
@@ -374,6 +395,7 @@ export class QueueService {
segmentCount: segmentCountCounts,
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
};
}
@@ -390,6 +412,7 @@ export class QueueService {
segmentCountQueue.pause(),
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
]);
}
@@ -406,6 +429,7 @@ export class QueueService {
segmentCountQueue.resume(),
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
]);
}
@@ -430,6 +454,8 @@ export class QueueService {
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
]);
}
@@ -514,6 +540,7 @@ export class QueueService {
segmentCountQueue.close(),
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
]);
}
}
+12 -11
View File
@@ -1,19 +1,21 @@
import {
S3Client,
PutObjectCommand,
CreateBucketCommand,
HeadBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import crypto from 'crypto';
import signale from 'signale';
import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET,
S3_BUCKET,
S3_PUBLIC_URL,
S3_FORCE_PATH_STYLE,
S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PUBLIC_URL,
} from '../app/constants.js';
/**
@@ -44,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
let bucketExists = true;
try {
// Check if bucket exists
await s3Client.send(
new HeadBucketCommand({
Bucket: S3_BUCKET,
@@ -69,13 +70,13 @@ export async function initializeBucket(): Promise<void> {
Bucket: S3_BUCKET,
}),
);
console.log(`[S3] Created bucket: ${S3_BUCKET}`);
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
} catch (createError) {
console.error('[S3] Failed to create bucket:', createError);
signale.error('[S3] Failed to create bucket:', createError);
throw createError;
}
} else {
console.error('[S3] Failed to check bucket:', error);
signale.error('[S3] Failed to check bucket:', error);
throw error;
}
}
@@ -103,10 +104,10 @@ export async function initializeBucket(): Promise<void> {
);
if (!bucketExists) {
console.log(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
}
} catch (policyError) {
console.error('[S3] Failed to set bucket policy:', policyError);
signale.error('[S3] Failed to set bucket policy:', policyError);
// Don't throw - bucket was created but policy failed
}
}
+32 -2
View File
@@ -1,4 +1,5 @@
import {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import {
AWS_SES_ACCESS_KEY_ID,
@@ -6,7 +7,8 @@ import {
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
} from '../app/constants.js';
/**
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
: ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
const configurationSetName =
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
// Send via SES
const response = await ses.sendRawEmail({
Destinations: destinations,
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING,
ConfigurationSetName: configurationSetName,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
ForwardingEnabled: false,
});
};
/**
* Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails
*/
export const getSendingQuota = async (): Promise<{
maxSendRate: number;
max24HourSend: number;
sentLast24Hours: number;
} | null> => {
try {
const quota = await ses.getSendQuota({});
return {
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
sentLast24Hours: quota.SentLast24Hours ?? 0,
};
} catch (error) {
signale.error('[SES] Failed to fetch sending quota:', error);
return null;
}
};
+98 -30
View File
@@ -1,9 +1,14 @@
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
@@ -14,10 +19,10 @@ const SECURITY_THRESHOLDS = {
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
BOUNCE_7DAY_WARNING: 3,
BOUNCE_7DAY_CRITICAL: 8,
BOUNCE_ALLTIME_WARNING: 2,
BOUNCE_ALLTIME_CRITICAL: 5,
BOUNCE_7DAY_WARNING: 5,
BOUNCE_7DAY_CRITICAL: 10,
BOUNCE_ALLTIME_WARNING: 4,
BOUNCE_ALLTIME_CRITICAL: 8,
// Complaint rate thresholds (spam reports)
COMPLAINT_7DAY_WARNING: 0.05,
@@ -45,7 +50,6 @@ interface SecurityStatus {
}
export class SecurityService {
private static readonly CACHE_PREFIX = 'security';
private static readonly CACHE_TTL = 300; // 5 minutes
/**
@@ -54,7 +58,7 @@ export class SecurityService {
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
try {
// Try to get from cache first
const cacheKey = this.getCacheKey(projectId, 'rates');
const cacheKey = Keys.Security.rates(projectId);
const cached = await redis.get(cacheKey);
if (cached) {
@@ -107,9 +111,31 @@ export class SecurityService {
// Get current security status
const status = await this.getSecurityStatus(projectId);
// If project should be disabled, disable it
if (status.shouldDisable) {
// If project should be disabled, disable it (only if auto-disable is enabled)
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
await this.disableProject(projectId, status);
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
// Log critical violations but don't auto-disable (self-hosted mode)
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
}
} else if (status.warnings.length > 0) {
// Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
@@ -137,13 +163,36 @@ export class SecurityService {
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const cacheKey = this.getCacheKey(projectId, 'rates');
const cacheKey = Keys.Security.rates(projectId);
await redis.del(cacheKey);
} catch (error) {
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
}
}
/**
* Check if a user is a member of any disabled project
* Users with disabled projects cannot create new projects
*/
public static async userHasDisabledProject(userId: string): Promise<{
hasDisabledProject: boolean;
disabledProjectNames: string[];
}> {
return MembershipService.userHasDisabledProject(userId);
}
/**
* Check if a specific project is disabled
*/
public static async isProjectDisabled(projectId: string): Promise<boolean> {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
return project?.disabled ?? false;
}
/**
* Get a project's security metrics (for admin/dashboard display)
*/
@@ -167,13 +216,6 @@ export class SecurityService {
};
}
/**
* Get cache key for security metrics
*/
private static getCacheKey(projectId: string, type: 'rates'): string {
return `${this.CACHE_PREFIX}:${projectId}:${type}`;
}
/**
* Calculate bounce and complaint rates for a project
*/
@@ -233,10 +275,11 @@ export class SecurityService {
const warnings: string[] = [];
// Only enforce if minimum emails threshold is met
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
if (hasMinimumVolume) {
// Check 7-day bounce rate
// Check 7-day bounce rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
@@ -246,7 +289,23 @@ export class SecurityService {
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
);
}
}
// Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
);
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
);
}
}
// Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
violations.push(
@@ -258,17 +317,6 @@ export class SecurityService {
);
}
// Check 7-day complaint rate
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
);
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
);
}
// Check all-time complaint rate
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
violations.push(
@@ -345,6 +393,26 @@ export class SecurityService {
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: status.violations,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
);
}
} catch (emailError) {
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
+160 -43
View File
@@ -1,5 +1,7 @@
import {type Contact, Prisma, type Segment} from '@plunk/db';
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
@@ -7,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* Convert segment name to a URL-safe slug for event names
* Example: "VIP Customers" -> "vip-customers"
@@ -71,9 +64,9 @@ export class SegmentService {
segmentId: string,
page = 1,
pageSize = 20,
): Promise<PaginatedContacts> {
): Promise<PaginatedResponse<Contact>> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize;
@@ -89,7 +82,7 @@ export class SegmentService {
]);
return {
contacts,
data: contacts,
total,
page,
pageSize,
@@ -121,7 +114,7 @@ export class SegmentService {
projectId,
name: data.name,
description: data.description,
condition: data.condition as unknown as Prisma.InputJsonValue,
condition: toPrismaJson(data.condition),
trackMembership: data.trackMembership ?? false,
memberCount,
},
@@ -168,7 +161,7 @@ export class SegmentService {
updateData.description = data.description;
}
if (data.condition !== undefined) {
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition);
@@ -236,7 +229,7 @@ export class SegmentService {
*/
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where});
@@ -267,7 +260,7 @@ export class SegmentService {
await Promise.all(
batch.map(async segment => {
try {
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where});
@@ -276,7 +269,7 @@ export class SegmentService {
data: {memberCount},
});
} catch (error) {
console.error(`Failed to update count for segment ${segment.id}:`, error);
signale.error(`Failed to update count for segment ${segment.id}:`, error);
}
}),
);
@@ -297,7 +290,7 @@ export class SegmentService {
throw new HttpException(400, 'Segment does not have membership tracking enabled');
}
const condition = segment.condition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues
@@ -363,15 +356,43 @@ export class SegmentService {
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
await prisma.segmentMembership.createMany({
data: batch.map(contactId => ({
// Check which contacts already have a membership record (inactive)
const existingMemberships = await prisma.segmentMembership.findMany({
where: {
segmentId,
contactId,
enteredAt: new Date(),
})),
skipDuplicates: true,
contactId: {in: batch},
},
select: {contactId: true},
});
const existingContactIds = new Set(existingMemberships.map(m => m.contactId));
const newEntries = batch.filter(id => !existingContactIds.has(id));
const reEntries = batch.filter(id => existingContactIds.has(id));
if (newEntries.length > 0) {
await prisma.segmentMembership.createMany({
data: newEntries.map(contactId => ({
segmentId,
contactId,
enteredAt: new Date(),
})),
skipDuplicates: true,
});
}
if (reEntries.length > 0) {
await prisma.segmentMembership.updateMany({
where: {
segmentId,
contactId: {in: reEntries},
},
data: {
exitedAt: null,
enteredAt: new Date(),
},
});
}
// Create segment-specific entry events for each contact in the batch
for (const contactId of batch) {
try {
@@ -383,7 +404,7 @@ export class SegmentService {
segmentName: segment.name,
});
} catch (error) {
console.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
}
}
}
@@ -414,7 +435,7 @@ export class SegmentService {
segmentName: segment.name,
});
} catch (error) {
console.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
}
}
}
@@ -425,7 +446,7 @@ export class SegmentService {
data: {memberCount: matchingContactIds.size},
});
console.log(
signale.info(
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
);
@@ -457,7 +478,7 @@ export class SegmentService {
// Handle JSON field paths (e.g., "data.plan")
if (field.startsWith('data.')) {
const jsonPath = field.substring(5); // Remove "data." prefix
return this.buildJsonFieldCondition(jsonPath, operator, value);
return this.buildJsonFieldCondition(jsonPath, operator, value, unit);
}
// Handle regular fields
@@ -495,6 +516,19 @@ export class SegmentService {
}
}
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
if (condition.logic === 'AND') {
return {AND: groupClauses};
} else {
return {OR: groupClauses};
}
}
/**
* Validate filter group (recursive)
*/
@@ -593,19 +627,6 @@ export class SegmentService {
};
}
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
if (condition.logic === 'AND') {
return {AND: groupClauses};
} else {
return {OR: groupClauses};
}
}
/**
* Build Prisma clause from filter group (recursive)
*/
@@ -637,13 +658,38 @@ export class SegmentService {
/**
* Build condition for JSON fields (stored in contact.data)
*/
private static buildJsonFieldCondition(jsonPath: string, operator: string, value: unknown): Prisma.ContactWhereInput {
private static buildJsonFieldCondition(
jsonPath: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
const path = jsonPath.split('.');
switch (operator) {
case 'equals':
// For date strings, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [
{data: {path, gte: startOfDay as Prisma.InputJsonValue}},
{data: {path, lt: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {data: {path, equals: value as Prisma.InputJsonValue}};
case 'notEquals':
// For date strings, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [
{data: {path, lt: startOfDay as Prisma.InputJsonValue}},
{data: {path, gte: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
case 'contains':
return {data: {path, string_contains: String(value)}};
@@ -669,6 +715,20 @@ export class SegmentService {
return {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
};
case 'within': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
throw new HttpException(400, 'Unit is required for "within" operator');
}
// Calculate the "since" date (X time units ago from now)
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
// Use ISO string for lexicographic comparison in JSON
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
}
default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
}
@@ -720,6 +780,28 @@ export class SegmentService {
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'equals': {
// For date fields, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [{[field]: {gte: new Date(startOfDay)}}, {[field]: {lt: new Date(startOfNextDay)}}],
};
}
// Exact timestamp match if not a date string
return {[field]: new Date(value as string | number | Date)};
}
case 'notEquals': {
// For date fields, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [{[field]: {lt: new Date(startOfDay)}}, {[field]: {gte: new Date(startOfNextDay)}}],
};
}
// Exclude exact timestamp if not a date string
return {NOT: {[field]: new Date(value as string | number | Date)}};
}
case 'greaterThan':
return {[field]: {gt: new Date(value as string | number | Date)}};
case 'lessThan':
@@ -761,6 +843,41 @@ export class SegmentService {
}
}
/**
* Check if a value is a date string in YYYY-MM-DD format
*/
private static isDateString(value: unknown): boolean {
if (typeof value !== 'string') return false;
// Match YYYY-MM-DD format (with optional time component)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T|$)/;
if (!dateRegex.test(value)) return false;
// Verify it's a valid date
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* Get date range for a date string (start of day to start of next day in UTC)
* @param value - Date string in YYYY-MM-DD format
* @returns Object with startOfDay and startOfNextDay as ISO strings
*/
private static getDateRange(value: string): {startOfDay: string; startOfNextDay: string} {
// Extract just the date part (YYYY-MM-DD)
const dateStr = value.split('T')[0];
const startOfDay = `${dateStr}T00:00:00.000Z`;
if (!dateStr) {
throw new HttpException(400, `Invalid date string: ${value}`);
}
// Calculate start of next day
const nextDay = new Date(dateStr);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const startOfNextDay = nextDay.toISOString().split('T')[0] + 'T00:00:00.000Z';
return {startOfDay, startOfNextDay};
}
/**
* Build condition for event-based filters
* Uses Prisma relations to efficiently query contacts who triggered specific events
+7 -14
View File
@@ -1,18 +1,11 @@
import type {Template} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
export interface PaginatedTemplates {
templates: Template[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export class TemplateService {
/**
* Get all templates for a project with pagination
@@ -23,7 +16,7 @@ export class TemplateService {
pageSize = 20,
search?: string,
type?: Template['type'],
): Promise<PaginatedTemplates> {
): Promise<PaginatedResponse<Template>> {
const skip = (page - 1) * pageSize;
const where: Prisma.TemplateWhereInput = {
@@ -51,7 +44,7 @@ export class TemplateService {
]);
return {
templates,
data: templates,
total,
page,
pageSize,
@@ -88,8 +81,8 @@ export class TemplateService {
subject: string;
body: string;
from: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
@@ -120,8 +113,8 @@ export class TemplateService {
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
@@ -1,14 +1,15 @@
import type {
Contact,
Prisma,
Template,
Workflow,
WorkflowExecution,
WorkflowStep,
WorkflowStepExecution,
Template,
Workflow,
} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
import {toPrismaJson} from '@plunk/types';
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
// Type aliases for workflow execution context
type StepConfig = Prisma.JsonValue;
type StepResult = Record<string, unknown>;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
// This is a delayed step - continue with execution
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
signale.info(
`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`,
);
return; // Already completed or cancelled
}
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: result ? (result as Prisma.InputJsonValue) : undefined,
output: result ? toPrismaJson(result) : undefined,
},
});
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: {
output: toPrismaJson({
eventName,
eventData: data ? (data as Prisma.InputJsonValue) : undefined,
eventData: data ? toPrismaJson(data) : undefined,
receivedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
}),
},
});
@@ -543,6 +545,7 @@ export class WorkflowExecutionService {
: {};
const variables = {
id: contact.id,
email: contact.email,
...contactData,
...executionContext,
@@ -866,7 +869,7 @@ export class WorkflowExecutionService {
await prisma.contact.update({
where: {id: contact.id},
data: {
data: newData ? (newData as Prisma.InputJsonValue) : undefined,
data: newData ? toPrismaJson(newData) : undefined,
},
});
+17 -32
View File
@@ -1,5 +1,8 @@
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
@@ -9,34 +12,16 @@ import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
export interface PaginatedWorkflows {
workflows: Workflow[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: WorkflowTransition[];
incomingTransitions: WorkflowTransition[];
})[];
}
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
workflow: Workflow;
contact: {id: string; email: string};
currentStep?: WorkflowStep | null;
stepExecutions: WorkflowStepExecution[];
}
export class WorkflowService {
/**
* Get all workflows for a project with pagination
*/
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
public static async list(
projectId: string,
page = 1,
pageSize = 20,
search?: string,
): Promise<PaginatedResponse<Workflow>> {
const skip = (page - 1) * pageSize;
const where: Prisma.WorkflowWhereInput = {
@@ -70,7 +55,7 @@ export class WorkflowService {
]);
return {
workflows: workflows as Workflow[],
data: workflows as Workflow[],
total,
page,
pageSize,
@@ -320,8 +305,8 @@ export class WorkflowService {
workflowId,
type: data.type,
name: data.name,
position: data.position as Prisma.InputJsonValue,
config: data.config as Prisma.InputJsonValue,
position: toPrismaJson(data.position),
config: toPrismaJson(data.config),
templateId: data.templateId,
},
});
@@ -402,8 +387,8 @@ export class WorkflowService {
const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
if (data.templateId !== undefined) {
if (data.templateId === null) {
updateData.template = {disconnect: true};
@@ -595,7 +580,7 @@ export class WorkflowService {
fromStepId: data.fromStepId,
condition: {
path: ['branch'],
equals: conditionObj.branch as Prisma.InputJsonValue,
equals: toPrismaJson(conditionObj.branch),
},
},
});
@@ -786,7 +771,7 @@ export class WorkflowService {
// Start executing the workflow asynchronously
// Don't await - let it run in background
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
console.error('Error executing workflow:', error);
signale.error('Error executing workflow:', error);
});
return execution;
@@ -1,10 +1,32 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
// Mock the stripe client to avoid actual Stripe API calls
vi.mock('../../app/stripe.js', () => ({
stripe: {
customers: {
retrieve: vi.fn().mockResolvedValue({
deleted: false,
currency: 'usd',
}),
},
},
}));
describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string;
let contactId: string;
@@ -298,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
data: {billingLimitCampaigns: 10},
});
// Create a date in the previous month
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
const lastMonth = new Date();
lastMonth.setDate(1);
lastMonth.setMonth(lastMonth.getMonth() - 1);
await prisma.email.create({
@@ -1,8 +1,18 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock STRIPE_ENABLED for billing limit tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
describe('CampaignService', () => {
let projectId: string;
const prisma = getPrismaClient();
@@ -154,7 +164,7 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
expect(result.campaigns).toHaveLength(10);
expect(result.data).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
expect(result.page).toBe(1);
@@ -167,8 +177,8 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.campaigns).toHaveLength(2);
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
expect(result.data).toHaveLength(2);
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
expect(matching).toBe(5);
});
});
describe('send', () => {
it('should throw error when campaign has no recipients', async () => {
// Create campaign with no contacts in project
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
});
it('should send campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Create 5 subscribed contacts
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
// Create campaign targeting all contacts
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (5 recipients < 100 limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw 403 error when campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails (usage = 5)
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 15 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
});
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 20
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 20},
});
// Create 15 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 15; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 25 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
/Cannot schedule campaign.*exceed billing limit/i,
);
});
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
// Set billing limit to null (unlimited)
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: null},
});
// Create 1000 subscribed contacts
for (let i = 0; i < 1000; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (no limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(1000);
});
it('should allow campaign that exactly reaches billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
for (let i = 0; i < 4; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully because 5 + 5 = 10 (exactly at limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw error when campaign has already been sent', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
'Campaign has already been sent or is currently sending',
);
});
it('should schedule campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 50},
});
// Create 10 subscribed contacts
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should schedule successfully (10 recipients < 50 limit)
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
expect(scheduledCampaign.totalRecipients).toBe(10);
});
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -439,4 +439,266 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
expect(unsubscribed?.subscribed).toBe(false);
});
});
describe('Bulk Contact Operations', () => {
describe('bulkSubscribe', () => {
it('should subscribe multiple unsubscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const contact3 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => c.subscribed)).toBe(true);
});
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [
unsubscribed1.id,
unsubscribed2.id,
alreadySubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: false});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(true);
expect(otherContactAfter?.subscribed).toBe(false);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkSubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
);
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkSubscribe(projectId, contactIds);
expect(result.updated).toBe(150);
const updatedContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
});
});
describe('bulkUnsubscribe', () => {
it('should unsubscribe multiple subscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const contact3 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => !c.subscribed)).toBe(true);
});
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
const subscribed1 = await factories.createContact({projectId, subscribed: true});
const subscribed2 = await factories.createContact({projectId, subscribed: true});
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [
subscribed1.id,
subscribed2.id,
alreadyUnsubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: true});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(false);
expect(otherContactAfter?.subscribed).toBe(true);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
});
describe('bulkDelete', () => {
it('should delete multiple contacts', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
const contact3 = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.deleted).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts).toHaveLength(0);
});
it('should only delete contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId});
const otherContact = await factories.createContact({projectId: otherProject.id});
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
expect(result.deleted).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter).toBeNull();
expect(otherContactAfter).not.toBeNull();
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkDelete(projectId, []);
expect(result.deleted).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.deleted).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkDelete(projectId, contactIds);
expect(result.deleted).toBe(200);
const remainingContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(remainingContacts).toHaveLength(0);
});
it('should delete both subscribed and unsubscribed contacts', async () => {
const subscribed = await factories.createContact({projectId, subscribed: true});
const unsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
expect(result.deleted).toBe(2);
});
it('should handle partial matches (some exist, some do not)', async () => {
const existingContact = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
expect(result.deleted).toBe(1);
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
expect(contact).toBeNull();
});
});
describe('Bulk Operations - Project Isolation', () => {
it('should never leak contacts between projects in bulk operations', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id},
});
const p2ContactsAfter = await prisma.contact.findMany({
where: {projectId: project2.id},
});
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
});
});
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js';
@@ -110,9 +110,7 @@ describe('DomainService', () => {
it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i,
@@ -122,13 +120,13 @@ describe('DomainService', () => {
it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject();
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
HttpException,
);
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not registered/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should throw error when domain belongs to different project', async () => {
@@ -141,13 +139,11 @@ describe('DomainService', () => {
data: {verified: true},
});
await expect(
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(/belongs to a different project/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
/belongs to a different project/i,
);
});
it('should throw error when domain is not verified', async () => {
@@ -155,13 +151,11 @@ describe('DomainService', () => {
await DomainService.addDomain(project.id, 'unverified.com');
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not verified/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not verified/i,
);
});
it('should return domain when all checks pass', async () => {
@@ -358,9 +352,9 @@ describe('DomainService', () => {
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
});
@@ -391,9 +385,7 @@ describe('DomainService', () => {
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*template/i,
);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
});
it('should throw error when domain is used in active campaigns', async () => {
@@ -409,9 +401,7 @@ describe('DomainService', () => {
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*campaign/i,
);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
});
it('should allow removal when campaign is SENT (completed)', async () => {
@@ -433,9 +423,9 @@ describe('DomainService', () => {
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
it('should check usage in multiple templates', async () => {
@@ -492,17 +482,15 @@ describe('DomainService', () => {
expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not registered/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
});
it('should handle email with multiple @ signs', async () => {
@@ -516,9 +504,7 @@ describe('DomainService', () => {
it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
});
});
@@ -537,11 +523,7 @@ describe('DomainService', () => {
]);
expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
});
it('should handle concurrent ownership checks', async () => {
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
@@ -669,7 +669,6 @@ describe('EmailService', () => {
// ========================================
describe('Attachment Schema Validation', () => {
it('should validate attachment count limit (max 10)', () => {
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
filename: `file${i}.txt`,
content: Buffer.from('content').toString('base64'),
@@ -690,7 +689,6 @@ describe('EmailService', () => {
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
const largeContent = 'A'.repeat(14000000);
@@ -711,11 +709,11 @@ describe('EmailService', () => {
});
it('should accept attachments within size limit', () => {
const validContent = Buffer.from('Small file content').toString('base64');
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
@@ -731,7 +729,6 @@ describe('EmailService', () => {
});
it('should reject attachment with missing required fields', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
@@ -748,7 +745,6 @@ describe('EmailService', () => {
});
it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
@@ -766,7 +762,6 @@ describe('EmailService', () => {
});
it('should reject attachment with filename exceeding 255 chars', () => {
const tooLongFilename = 'a'.repeat(256) + '.pdf';
const result = ActionSchemas.send.safeParse({
@@ -786,18 +781,12 @@ describe('EmailService', () => {
});
it('should accept valid attachment with various content types', () => {
const contentTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'text/plain',
'application/zip',
];
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
for (const contentType of contentTypes) {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
import {EventService} from '../EventService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -503,7 +504,7 @@ describe('EventService', () => {
const {redis} = await import('../../database/redis');
// Set cache
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
// Verify cache exists
@@ -852,4 +853,90 @@ describe('EventService', () => {
});
});
});
// ========================================
// RESERVED EVENT VALIDATION
// ========================================
describe('isReservedEvent', () => {
describe('Email events (email.*)', () => {
it('should identify email.sent as reserved', () => {
expect(EventService.isReservedEvent('email.sent')).toBe(true);
});
it('should identify email.delivery as reserved', () => {
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
});
it('should identify email.open as reserved', () => {
expect(EventService.isReservedEvent('email.open')).toBe(true);
});
it('should identify email.click as reserved', () => {
expect(EventService.isReservedEvent('email.click')).toBe(true);
});
it('should identify email.bounce as reserved', () => {
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
});
it('should identify email.complaint as reserved', () => {
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
});
it('should identify any email.* pattern as reserved', () => {
expect(EventService.isReservedEvent('email.custom')).toBe(true);
expect(EventService.isReservedEvent('email.anything')).toBe(true);
});
});
describe('Contact events', () => {
it('should identify contact.subscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
});
it('should identify contact.unsubscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
});
it('should not identify other contact.* events as reserved', () => {
expect(EventService.isReservedEvent('contact.created')).toBe(false);
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
});
});
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
it('should identify segment.*.entry as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
});
it('should identify segment.*.exit as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
});
it('should not identify other segment.* events as reserved', () => {
expect(EventService.isReservedEvent('segment.created')).toBe(false);
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
});
});
describe('Non-reserved events', () => {
it('should not identify custom user events as reserved', () => {
expect(EventService.isReservedEvent('user.signup')).toBe(false);
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
expect(EventService.isReservedEvent('order.placed')).toBe(false);
expect(EventService.isReservedEvent('custom.event')).toBe(false);
});
it('should not identify events with similar prefixes as reserved', () => {
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(subscribed.id);
});
it('should filter contacts by custom data fields', async () => {
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(proUser.id);
});
it('should filter contacts with multiple conditions', async () => {
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(target.id);
});
it('should support notEquals operator', async () => {
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(pro.id);
});
it('should support contains operator for strings', async () => {
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should support exists operator for custom fields', async () => {
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withField.id);
});
it('should handle empty segments', async () => {
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
it('should support pagination', async () => {
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
expect(page1.contacts).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
expect(page2.contacts).toHaveLength(10);
expect(page2.data).toHaveLength(10);
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
expect(page3.contacts).toHaveLength(5);
expect(page3.data).toHaveLength(5);
});
});
@@ -278,7 +278,7 @@ describe('SegmentService', () => {
// Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
// Update contact to pro plan
await prisma.contact.update({
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
// Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(contact.id);
});
it('should be removed from segment when criteria no longer met', async () => {
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
// Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.data).toHaveLength(1);
// Unsubscribe contact
await prisma.contact.update({
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
// Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
});
@@ -506,8 +506,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.data.map(c => c.id)).not.toContain(match.id);
});
it('should support case-insensitive equals/contains for email strings', async () => {
@@ -526,7 +526,7 @@ describe('SegmentService', () => {
});
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
const equalsIds = equalsResult.contacts.map(c => c.id);
const equalsIds = equalsResult.data.map(c => c.id);
expect(equalsIds).toContain(lower.id);
expect(equalsIds).toContain(upper.id);
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
});
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
const containsIds = containsResult.contacts.map(c => c.id);
const containsIds = containsResult.data.map(c => c.id);
expect(containsIds).toContain(lower.id);
expect(containsIds).toContain(upper.id);
});
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id);
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
});
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
const notContainsIds = notContainsResult.contacts.map(c => c.id);
const notContainsIds = notContainsResult.data.map(c => c.id);
expect(notContainsIds).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id);
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
});
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
const notEqualsIds = notEqualsResult.data.map(c => c.id);
expect(notEqualsIds).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id);
});
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
});
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
const existsIds = new Set(existsResult.contacts.map(c => c.id));
const existsIds = new Set(existsResult.data.map(c => c.id));
expect(existsIds.has(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false);
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
});
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
expect(notExistsIds.has(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
});
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
});
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
const gtIds = greaterThanResult.contacts.map(c => c.id);
const gtIds = greaterThanResult.data.map(c => c.id);
expect(gtIds).toContain(mid.id);
expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id);
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
});
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(low.id);
expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id);
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
});
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
const gtIds = gtResult.contacts.map(c => c.id);
const gtIds = gtResult.data.map(c => c.id);
expect(gtIds).toContain(newer.id);
expect(gtIds).not.toContain(older.id);
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
});
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(older.id);
expect(lteIds).toContain(newer.id);
});
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(recent.id);
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
}
const page1 = await TemplateService.list(projectId, 1, 10);
expect(page1.templates).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10);
expect(page2.data).toHaveLength(10);
expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5);
expect(page3.data).toHaveLength(5);
expect(page3.page).toBe(3);
});
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should filter templates by search query (description)', async () => {
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2);
expect(result.templates.map(t => t.description)).toEqual(
expect(result.data.map(t => t.description)).toEqual(
expect.arrayContaining([expect.stringContaining('new')]),
);
});
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2);
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1);
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
});
it('should combine search and type filters', async () => {
@@ -223,7 +223,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1);
expect(result.templates[0].name).toBe('Welcome Email');
expect(result.data[0].name).toBe('Welcome Email');
});
it('should return templates ordered by creation date (newest first)', async () => {
@@ -236,9 +236,9 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest
expect(result.data[0].id).toBe(template3.id); // Newest
expect(result.data[1].id).toBe(template2.id);
expect(result.data[2].id).toBe(template1.id); // Oldest
});
it('should only return templates for the specified project', async () => {
@@ -1,5 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium Status',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.isPremium',
operator: 'equals',
value: true,
},
}),
},
});
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Premium Path',
position: {x: 200, y: -50},
config: {reason: 'Premium customer'},
config: toPrismaJson({reason: 'Premium customer'}),
},
});
@@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Standard Path',
position: {x: 200, y: 50},
config: {reason: 'Standard customer'},
config: toPrismaJson({reason: 'Standard customer'}),
},
});
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
priority: 1,
},
});
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
priority: 2,
},
});
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.isPremium',
operator: 'equals',
value: true,
},
}),
},
});
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Premium',
position: {x: 200, y: -50},
config: {},
config: toPrismaJson({}),
},
});
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Standard',
position: {x: 200, y: 50},
config: {},
config: toPrismaJson({}),
},
});
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Country',
position: {x: 100, y: 0},
config: {field: 'data.country', operator: 'equals', value: 'US'},
config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}),
},
});
@@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium (US)',
position: {x: 200, y: -50},
config: {field: 'data.isPremium', operator: 'equals', value: true},
config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}),
},
});
@@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'US Premium',
position: {x: 300, y: -75},
config: {},
config: toPrismaJson({}),
},
});
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'US Standard',
position: {x: 300, y: -25},
config: {},
config: toPrismaJson({}),
},
});
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Non-US',
position: {x: 200, y: 50},
config: {},
config: toPrismaJson({}),
},
});
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition1.id,
toStepId: condition2.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition1.id,
toStepId: nonUsExit.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition2.id,
toStepId: usPremiumExit.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition2.id,
toStepId: usStandardExit.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Purchase',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
eventName: 'purchase.completed',
timeout: 3600, // 1 hour
},
}),
},
});
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Event',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
eventName: 'user.verified',
timeout: 3600,
},
}),
},
});
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Wait 1 day',
position: {x: 100, y: 0},
config: {amount: 1, unit: 'days'},
config: toPrismaJson({amount: 1, unit: 'days'}),
},
});
@@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Status',
position: {x: 200, y: 0},
config: {field: 'contact.subscribed', operator: 'equals', value: true},
config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}),
},
});
@@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 300, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: exit.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'A/B Split',
position: {x: 100, y: 0},
config: {field: 'data.segment', operator: 'equals', value: 'A'},
config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}),
},
});
@@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Path A Delay',
position: {x: 200, y: -50},
config: {amount: 1, unit: 'hours'},
config: toPrismaJson({amount: 1, unit: 'hours'}),
},
});
@@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Path B Delay',
position: {x: 200, y: 50},
config: {amount: 2, unit: 'hours'},
config: toPrismaJson({amount: 2, unit: 'hours'}),
},
});
@@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Merge Point',
position: {x: 300, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: pathA.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: pathB.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Bad Condition',
position: {x: 100, y: 0},
config: {}, // Invalid - missing required fields
config: toPrismaJson({}), // Invalid - missing required fields
},
});
@@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Missing Field',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.nonExistentField',
operator: 'equals',
value: 'something',
},
}),
},
});
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Exit',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Early Exit',
position: {x: 100, y: 0},
config: {reason: 'User already converted'},
config: toPrismaJson({reason: 'User already converted'}),
},
});
@@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.TRIGGER,
name: 'Start',
position: {x: 0, y: 0},
config: {},
config: toPrismaJson({}),
});
const exitStep = await factories.createWorkflowStep({
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'End',
position: {x: 100, y: 0},
config: {},
config: toPrismaJson({}),
});
await prisma.workflowTransition.create({
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: contextData as Prisma.InputJsonValue,
context: toPrismaJson(contextData),
},
});
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check if first open',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.isFirstOpen',
operator: 'equals',
value: true, // Use boolean, not string
} as Prisma.InputJsonValue,
}),
},
});
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'First Open',
position: {x: 200, y: 0},
config: {reason: 'first_open'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'first_open'}),
},
});
@@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Not First Open',
position: {x: 200, y: 100},
config: {reason: 'not_first_open'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'not_first_open'}),
},
});
@@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue},
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})},
});
await prisma.workflowTransition.create({
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue},
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})},
});
// Create execution with event data
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome Email',
from: '[email protected]',
isFirstOpen: true,
openedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
}),
},
});
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check subject',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.subject',
operator: 'contains',
value: 'Welcome',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {reason: 'matched'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'matched'}),
},
});
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: exitStep.id,
condition: {branch: 'yes'} as Prisma.InputJsonValue,
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome to Plunk!',
from: '[email protected]',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check opens count',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.opens',
operator: 'greaterThan',
value: '3',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {reason: 'engaged'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'engaged'}),
},
});
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: exitStep.id,
condition: {branch: 'yes'} as Prisma.InputJsonValue,
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Newsletter',
opens: 5,
} as Prisma.InputJsonValue,
}),
},
});
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WEBHOOK,
name: 'Send Webhook',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
url: 'https://webhook.example.com/test',
method: 'POST',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome Email',
from: '[email protected]',
messageId: 'msg-123',
isFirstOpen: true,
openedAt: '2024-01-15T10:00:00Z',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1,9 +1,9 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {
WorkflowStepType,
StepExecutionStatus,
WorkflowExecutionStatus,
TemplateType,
WorkflowExecutionStatus,
WorkflowStepType,
WorkflowTriggerType,
} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowService} from '../WorkflowService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -130,7 +131,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when creating enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
// Set cache
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
@@ -204,7 +205,7 @@ describe('WorkflowService', () => {
const page1 = await WorkflowService.list(projectId, 1, 10);
expect(page1.workflows).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
});
@@ -217,7 +218,7 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should include step and execution counts', async () => {
@@ -233,8 +234,8 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId);
const found = result.workflows.find(w => w.id === workflow.id) as
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}})
const found = result.data.find(w => w.id === workflow.id) as
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
| undefined;
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
expect(found?._count.executions).toBe(1);
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when enabling workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when deleting enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({
projectId,
+80
View File
@@ -6,6 +6,18 @@ export const Keys = {
email(email: string): string {
return `account:${email}`;
},
emailVerificationToken(token: string): string {
return `auth:email_verification:${token}`;
},
passwordResetToken(token: string): string {
return `auth:password_reset:${token}`;
},
emailVerificationRateLimit(userId: string): string {
return `auth:email_verification_rate:${userId}`;
},
passwordResetRateLimit(email: string): string {
return `auth:password_reset_rate:${email}`;
},
},
Domain: {
id(id: string): string {
@@ -14,5 +26,73 @@ export const Keys = {
project(projectId: string): string {
return `domain:project:${projectId}`;
},
verifiedEmail(domainId: string): string {
return `domain:verified_email:${domainId}`;
},
unverifiedEmail(domainId: string, year: number, month: string): string {
return `domain:unverified_email:${domainId}:${year}-${month}`;
},
},
Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:usage:${projectId}:${sourceType}:${year}-${month}`;
},
warningEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:warning_email:${projectId}:${sourceType}:${year}-${month}`;
},
limitEmail(projectId: string, sourceType: string, year: number, month: string): string {
return `billing:limit_email:${projectId}:${sourceType}:${year}-${month}`;
},
},
Security: {
rates(projectId: string): string {
return `security:${projectId}:rates`;
},
},
Activity: {
stats(projectId: string, startTime: number | string, endTime: number | string): string {
return `activity:stats:${projectId}:${startTime}:${endTime}`;
},
},
Analytics: {
timeseries(projectId: string, startDate: string, endDate: string): string {
return `analytics:timeseries:${projectId}:${startDate}:${endDate}`;
},
campaignStats(projectId: string, startDate: string, endDate: string): string {
return `analytics:campaignStats:${projectId}:${startDate}:${endDate}`;
},
topEvents(projectId: string, limit: number, startDate: string, endDate: string): string {
return `analytics:topEvents:${projectId}:${limit}:${startDate}:${endDate}`;
},
},
Workflow: {
enabled(projectId: string): string {
return `workflows:enabled:${projectId}`;
},
},
Membership: {
access(userId: string, projectId: string): string {
return `membership:access:${userId}:${projectId}`;
},
admin(userId: string, projectId: string): string {
return `membership:admin:${userId}:${projectId}`;
},
full(userId: string, projectId: string): string {
return `membership:full:${userId}:${projectId}`;
},
owner(projectId: string): string {
return `membership:owner:${projectId}`;
},
},
Project: {
id(id: string): string {
return `project:id:${id}`;
},
secret(key: string): string {
return `project:secret:${key}`;
},
public(key: string): string {
return `project:public:${key}`;
},
},
} as const;
+2 -2
View File
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
subject?: string;
body?: string;
from?: string;
fromName?: string;
replyTo?: string;
fromName?: string | null;
replyTo?: string | null;
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
return buildUpdateData(data) as Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput;
}
+1 -1
View File
@@ -1,5 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.swyp.be',
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
generateRobotsTxt: true,
};
+15 -3
View File
@@ -14,11 +14,23 @@
"@plunk/db": "*",
"@plunk/shared": "*",
"@plunk/ui": "*",
"@tiptap/core": "^3.11.0",
"@tiptap/extension-color": "^3.11.0",
"@tiptap/extension-image": "^3.11.0",
"@tiptap/extension-link": "^3.11.0",
"@tiptap/extension-placeholder": "^3.11.0",
"@tiptap/extension-text-align": "^3.11.0",
"@tiptap/extension-text-style": "^3.11.0",
"@tiptap/extension-underline": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/react": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.7",
"next": "^16.0.10",
"next-seo": "^6.6.0",
"react": "19.2.1",
"react-dom": "19.2.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.6"
},
"devDependencies": {
+66
View File
@@ -0,0 +1,66 @@
import {motion} from 'framer-motion';
import React, {useState} from 'react';
import {Check, Copy} from 'lucide-react';
interface CodeBlockProps {
code: string;
language?: string;
title?: string;
showCopy?: boolean;
}
/**
* Reusable code block component with syntax highlighting styling and copy functionality
*/
export function CodeBlock({code, language = 'javascript', title, showCopy = true}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'group relative overflow-hidden rounded-xl border border-neutral-200 bg-neutral-900 w-full max-w-full'}
>
{(title || showCopy) && (
<div className={'flex items-center justify-between border-b border-neutral-800 bg-neutral-900 px-6 py-3'}>
{title && <span className={'text-sm font-medium text-neutral-400'}>{title}</span>}
{!title && <span className={'text-xs font-medium text-neutral-500 uppercase'}>{language}</span>}
{showCopy && (
<button
onClick={handleCopy}
className={
'flex items-center gap-2 rounded-lg border border-neutral-700 bg-neutral-800 px-3 py-1.5 text-xs font-medium text-neutral-300 transition hover:bg-neutral-700 hover:text-white'
}
aria-label="Copy code"
>
{copied ? (
<>
<Check className="h-3 w-3" />
Copied!
</>
) : (
<>
<Copy className="h-3 w-3" />
Copy
</>
)}
</button>
)}
</div>
)}
<pre className={'overflow-x-auto p-6 text-neutral-100 w-full max-w-full'} style={{boxSizing: 'border-box'}}>
<code className={'font-mono text-sm leading-relaxed'} style={{whiteSpace: 'pre-wrap', wordBreak: 'break-word'}}>
{code}
</code>
</pre>
</motion.div>
);
}
@@ -0,0 +1,80 @@
import {Check, X} from 'lucide-react';
import {motion} from 'framer-motion';
import React from 'react';
export interface ComparisonRow {
feature: string;
plunk: boolean | string;
competitor: boolean | string;
}
interface ComparisonTableProps {
competitorName: string;
rows: ComparisonRow[];
}
/**
* Reusable comparison table component for competitor pages
*/
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
return (
<div className={'overflow-hidden rounded-xl border border-neutral-200'}>
{/* Header */}
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
<div className={'bg-white p-6'}>
<span className={'text-sm font-semibold text-neutral-900'}>Feature</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
</div>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
</div>
</div>
{/* Rows */}
<div className={'grid gap-px bg-neutral-200'}>
{rows.map((row, index) => (
<motion.div
key={row.feature}
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-3 gap-px bg-neutral-200'}
>
<div className={'bg-white p-6'}>
<span className={'text-sm text-neutral-600'}>{row.feature}</span>
</div>
<div className={'bg-white p-6'}>
<div className={'flex justify-center'}>
{typeof row.plunk === 'boolean' ? (
row.plunk ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
)}
</div>
</div>
<div className={'bg-white p-6'}>
<div className={'flex justify-center'}>
{typeof row.competitor === 'boolean' ? (
row.competitor ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
)}
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import {motion} from 'framer-motion';
import Script from 'next/script';
import React from 'react';
export interface FAQ {
question: string;
answer: string;
}
interface FAQSectionProps {
faqs: FAQ[];
schemaId?: string;
}
/**
* Reusable FAQ section component with structured data support
*/
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
return (
<>
<Script
id={schemaId}
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
'mainEntity': faqs.map(faq => ({
'@type': 'Question',
'name': faq.question,
'acceptedAnswer': {
'@type': 'Answer',
'text': faq.answer,
},
})),
}),
}}
/>
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl'}
>
<h2 className={'mb-16 text-center text-5xl font-bold tracking-tight text-neutral-900'}>
Frequently asked questions
</h2>
<div className={'space-y-8'}>
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'border-b border-neutral-200 pb-8 last:border-b-0'}
>
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
</motion.div>
))}
</div>
</motion.div>
</section>
</>
);
}
@@ -67,7 +67,10 @@ export default function Footer() {
</Link>
</li>
<li>
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
<Link
href={'/made-by-humans'}
className="text-sm text-neutral-600 transition hover:text-neutral-900"
>
Made by humans
</Link>
</li>
@@ -131,9 +134,7 @@ export default function Footer() {
</div>
<div className="mt-16 border-t border-neutral-200 pt-8">
<p className="text-sm text-neutral-500">
&copy; {new Date().getFullYear()} Plunk. All rights reserved.
</p>
<p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p>
</div>
</div>
</footer>
+36 -18
View File
@@ -12,7 +12,7 @@ export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false);
return (
<nav className={'top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
<nav className={'relative top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
<div className={'z-40 py-6'}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-12">
@@ -25,19 +25,34 @@ export default function Navbar() {
</Link>
</div>
<div className="hidden items-center gap-8 md:flex">
<Link href={'/made-by-humans'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<Link
href={'/made-by-humans'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
By humans
</Link>
<Link href={'/pricing'} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<Link
href={'/pricing'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Pricing
</Link>
<Link
href={'/guides'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Guides
</Link>
<Link
href={WIKI_URI}
target={'_blank'}
rel={'noreferrer'}
className={'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
className={
'flex items-center gap-x-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
}
>
Docs
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -47,32 +62,27 @@ export default function Navbar() {
strokeWidth="2"
d="M9.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V14.75"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19.25 9.25V4.75H14.75"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 5L11.75 12.25"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
</svg>
</Link>
</div>
</div>
<div className="hidden items-center gap-6 md:flex">
<a href={`${DASHBOARD_URI}/auth/login`} className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}>
<a
href={`${DASHBOARD_URI}/auth/login`}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Sign in
</a>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'}
className={
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
}
>
Get started
</motion.a>
@@ -146,6 +156,14 @@ export default function Navbar() {
Pricing
</Link>
<Link
href={'/guides'}
onClick={() => setMobileOpen(false)}
className="block rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 transition hover:bg-neutral-100 hover:text-neutral-900"
>
Guides
</Link>
<a
href={WIKI_URI}
target={'_blank'}
@@ -0,0 +1,244 @@
import {Footer, Navbar} from '../';
import {motion} from 'framer-motion';
import React, {ReactNode, useLayoutEffect, useState} from 'react';
import Link from 'next/link';
import {ArticleJsonLd, BreadcrumbJsonLd, NextSeo} from 'next-seo';
import {Calendar, Clock} from 'lucide-react';
interface GuideLayoutProps {
title: string;
description: string;
lastUpdated: string;
readTime: string;
children: ReactNode;
canonical?: string;
ogImage?: string;
}
/**
* Reusable layout for educational guide pages
*/
export function GuideLayout({
title,
description,
lastUpdated,
readTime,
children,
canonical,
ogImage = 'https://next.useplunk.com/assets/card.png',
}: GuideLayoutProps) {
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
// Extract headings for table of contents
useLayoutEffect(() => {
const elements = Array.from(document.querySelectorAll('h2, h3'));
// Generate IDs for headings that don't have them
const headingData = elements.map(element => {
let id = element.id;
if (!id) {
// Generate ID from text content
id = (element.textContent || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
element.id = id;
}
return {
id,
text: element.textContent || '',
level: parseInt(element.tagName.substring(1)),
};
});
// eslint-disable-next-line react-hooks/set-state-in-effect
setHeadings(headingData);
// Set up intersection observer for active heading
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{rootMargin: '-100px 0px -80% 0px'},
);
elements.forEach(element => observer.observe(element));
return () => observer.disconnect();
}, []);
// Generate breadcrumb items
const breadcrumbItems = [
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
{position: 3, name: title, item: canonical || ''},
];
return (
<>
<NextSeo
title={`${title} | Plunk`}
description={description}
canonical={canonical}
openGraph={{
title: `${title} | Plunk`,
description: description,
url: canonical,
type: 'article',
images: [{url: ogImage, alt: title}],
article: {
publishedTime: lastUpdated,
modifiedTime: lastUpdated,
authors: ['Plunk'],
},
}}
/>
<ArticleJsonLd
type="Article"
url={canonical || ''}
title={title}
images={[ogImage]}
datePublished={lastUpdated}
dateModified={lastUpdated}
authorName="Plunk"
description={description}
/>
<BreadcrumbJsonLd itemListElements={breadcrumbItems} />
<Navbar />
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
<div className={'flex flex-col lg:flex-row gap-8 lg:gap-12 py-8 sm:py-16 w-full'}>
{/* Main Content */}
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
{/* Breadcrumbs */}
<nav className={'mb-6 sm:mb-8 w-full overflow-x-auto'}>
<ol
className={
'flex flex-wrap items-center gap-1 sm:gap-2 text-xs sm:text-sm text-neutral-600 whitespace-normal'
}
>
<li className={'truncate max-w-[90vw]'}>
<Link href="/" className={'hover:text-neutral-900'}>
Home
</Link>
</li>
<li>/</li>
<li className={'truncate max-w-[90vw]'}>
<Link href="/guides" className={'hover:text-neutral-900'}>
Guides
</Link>
</li>
<li>/</li>
<li className={'text-neutral-900 font-medium truncate max-w-[90vw]'}>{title}</li>
</ol>
</nav>
{/* Header */}
<motion.header
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-8 sm:mb-12 w-full'}
>
<h1 className={'text-2xl sm:text-4xl font-bold tracking-tight text-neutral-900 break-words max-w-full'}>
{title}
</h1>
<p
className={'mt-4 sm:mt-6 text-base sm:text-lg text-neutral-600 leading-relaxed break-words max-w-full'}
>
{description}
</p>
<div
className={
'mt-6 sm:mt-8 flex flex-wrap items-center gap-4 sm:gap-6 text-xs sm:text-sm text-neutral-600'
}
>
<div className={'flex items-center gap-2'}>
<Calendar className="h-4 w-4" />
<span>
Updated{' '}
{new Date(lastUpdated).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</span>
</div>
<div className={'flex items-center gap-2'}>
<Clock className="h-4 w-4" />
<span>{readTime} read</span>
</div>
</div>
</motion.header>
{/* Content */}
<div className={'prose prose-neutral max-w-full'} style={{overflowX: 'visible'}}>
{/* Responsive table and code block styles */}
<style>{`
.prose table { display: block; width: 100%; overflow-x: auto; }
.prose th, .prose td { white-space: normal; word-break: break-word; }
.prose pre, .prose code { max-width: 100vw; overflow-x: auto; word-break: break-word; }
.prose img { max-width: 100%; height: auto; }
`}</style>
{children}
</div>
</article>
{/* Table of Contents - Desktop Only */}
{headings.length > 0 && (
<aside className={'hidden lg:block w-64 shrink-0 sticky top-24 self-start'}>
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>On this page</h2>
<nav>
<ul className={'space-y-1'}>
{headings.map(heading => (
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const offset = 100; // Account for fixed header
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - offset,
behavior: 'smooth',
});
}
}}
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
heading.level === 2
? activeId === heading.id
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
: activeId === heading.id
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
}`}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
</div>
</aside>
)}
</div>
</main>
<Footer />
</>
);
}
@@ -0,0 +1,82 @@
import React, {ReactNode} from 'react';
import {AlertTriangle, CheckCircle2, Info, Lightbulb} from 'lucide-react';
type InfoBoxType = 'info' | 'warning' | 'tip' | 'success';
interface InfoBoxProps {
type?: InfoBoxType;
title?: string;
children: ReactNode;
className?: string;
}
const infoBoxConfig: Record<
InfoBoxType,
{
icon: React.ComponentType<{className?: string}>;
borderColor: string;
bgColor: string;
iconColor: string;
titleColor: string;
}
> = {
info: {
icon: Info,
borderColor: 'border-blue-200',
bgColor: 'bg-blue-50',
iconColor: 'text-blue-600',
titleColor: 'text-blue-900',
},
warning: {
icon: AlertTriangle,
borderColor: 'border-amber-200',
bgColor: 'bg-amber-50',
iconColor: 'text-amber-600',
titleColor: 'text-amber-900',
},
tip: {
icon: Lightbulb,
borderColor: 'border-purple-200',
bgColor: 'bg-purple-50',
iconColor: 'text-purple-600',
titleColor: 'text-purple-900',
},
success: {
icon: CheckCircle2,
borderColor: 'border-green-200',
bgColor: 'bg-green-50',
iconColor: 'text-green-600',
titleColor: 'text-green-900',
},
};
/**
* InfoBox component for displaying tips, warnings, notes, and other callouts in guides
*/
export function InfoBox({type = 'info', title, children, className}: InfoBoxProps) {
const config = infoBoxConfig[type];
const Icon = config.icon;
const defaultTitles: Record<InfoBoxType, string> = {
info: 'Note',
warning: 'Warning',
tip: 'Tip',
success: 'Success',
};
return (
<div className={`rounded-xl border ${config.borderColor} ${config.bgColor} p-6 my-6 ${className || ''}`}>
<div className={'flex gap-4'}>
<div className={'shrink-0'}>
<Icon className={`h-5 w-5 ${config.iconColor}`} />
</div>
<div>
{(title || defaultTitles[type]) && (
<h4 className={`not-prose font-semibold ${config.titleColor}`}>{title || defaultTitles[type]}</h4>
)}
<div className={'text-sm text-neutral-700 leading-relaxed mt-0 prose prose-sm max-w-none'}>{children}</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,2 @@
export {GuideLayout} from './GuideLayout';
export {InfoBox} from './InfoBox';
+3
View File
@@ -1,2 +1,5 @@
export * from './Navbar';
export * from './Footer';
export * from './ComparisonTable';
export * from './FAQSection';
export * from './CodeBlock';
@@ -0,0 +1,177 @@
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Info,
Mail,
Server,
Shield,
Trash2,
XCircle,
Forward,
} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
interface EmailVerificationResultProps {
result: VerificationResult;
}
export function EmailVerificationResult({result}: EmailVerificationResultProps) {
return (
<div className="space-y-6">
{/* Overall Status */}
<div
className={`rounded-lg border-2 p-6 ${
result.valid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
}`}
>
<div className="flex items-center gap-3">
{result.valid ? (
<CheckCircle className="h-8 w-8 text-green-600" />
) : (
<XCircle className="h-8 w-8 text-red-600" />
)}
<div>
<h3 className={`text-xl font-semibold ${result.valid ? 'text-green-900' : 'text-red-900'}`}>
{result.valid ? 'Valid Email' : 'Invalid Email'}
</h3>
<p className={`text-sm ${result.valid ? 'text-green-700' : 'text-red-700'}`}>{result.email}</p>
</div>
</div>
</div>
{/* Detailed Checks */}
<div className="rounded-lg border border-neutral-200 bg-white">
<div className="border-b border-neutral-200 bg-neutral-50 px-6 py-4">
<h4 className="font-semibold text-neutral-900">Verification Details</h4>
</div>
<div className="divide-y divide-neutral-200">
{/* Domain Exists */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Domain Exists</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
</div>
</div>
{result.domainExists ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<XCircle className="h-5 w-5 text-red-600" />
)}
</div>
{/* MX Records */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Mail className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">MX Records</p>
<p className="text-sm text-neutral-600">Mail server configured</p>
</div>
</div>
{result.hasMxRecords ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<XCircle className="h-5 w-5 text-red-600" />
)}
</div>
{/* Disposable Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Trash2 className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Disposable Email</p>
<p className="text-sm text-neutral-600">Temporary email service</p>
</div>
</div>
{result.isDisposable ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Alias/Forwarding Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Forward className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Forwarding Service</p>
<p className="text-sm text-neutral-600">Email alias/forwarding detected</p>
</div>
</div>
{result.isAlias ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Typo Detection */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Typo Check</p>
<p className="text-sm text-neutral-600">Common spelling errors</p>
</div>
</div>
{result.isTypo ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Plus Addressing */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Plus Addressing</p>
<p className="text-sm text-neutral-600">Uses + tag (user+tag@domain.com)</p>
</div>
</div>
{result.isPlusAddressed ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<span className="text-sm text-neutral-500">No</span>
)}
</div>
</div>
</div>
{/* Suggested Email (if typo detected) */}
{result.suggestedEmail && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5" />
<div>
<p className="font-medium text-yellow-900">Did you mean?</p>
<p className="text-sm text-yellow-700 mt-1">
<span className="font-mono bg-yellow-100 px-2 py-0.5 rounded">{result.suggestedEmail}</span>
</p>
</div>
</div>
</div>
)}
{/* Reasons */}
{result.reasons && result.reasons.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h4 className="font-semibold text-neutral-900 mb-3">Analysis</h4>
<ul className="space-y-2">
{result.reasons.map((reason, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-neutral-700 list-disc list-inside">
<span>{reason}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,77 @@
import {EditorContent, useEditor} from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import {TextAlign} from '@tiptap/extension-text-align';
import {Color} from '@tiptap/extension-color';
import {TextStyle} from '@tiptap/extension-text-style';
import {Link} from '@tiptap/extension-link';
import {Underline} from '@tiptap/extension-underline';
import {Image} from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import {MarkdownEmailToolbar} from './MarkdownEmailToolbar';
import {useEffect} from 'react';
interface MarkdownEmailEditorProps {
value: string;
onChange: (value: string) => void;
}
export function MarkdownEmailEditor({value, onChange}: MarkdownEmailEditorProps) {
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3],
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
}),
Color,
TextStyle,
Underline,
Link.configure({
openOnClick: false,
HTMLAttributes: {
rel: 'noopener noreferrer',
},
}),
Image.configure({
HTMLAttributes: {
class: 'email-image',
},
inline: false,
}),
Placeholder.configure({
placeholder: 'Start typing your email here...',
}),
],
content: value || '',
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[500px] px-4 py-3 text-neutral-900',
},
},
onUpdate: ({editor}) => {
const html = editor.getHTML();
onChange(html);
},
});
// Update editor content when value prop changes from outside
useEffect(() => {
if (editor && value !== editor.getHTML()) {
editor.commands.setContent(value || '');
}
}, [value, editor]);
return (
<div className="border border-neutral-200 rounded-lg bg-white">
<MarkdownEmailToolbar editor={editor} />
<div className="overflow-hidden">
<EditorContent editor={editor} className="bg-white" />
</div>
</div>
);
}
@@ -0,0 +1,492 @@
import {type Editor} from '@tiptap/react';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Image as ImageIcon,
Italic,
Link,
List,
ListOrdered,
Palette,
Quote,
Redo,
Strikethrough,
Underline as UnderlineIcon,
Undo,
} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {useCallback, useState} from 'react';
import {EDITOR_COLOR_GROUPS} from '../../lib/editorColors';
interface ToolbarProps {
editor: Editor | null;
}
export function MarkdownEmailToolbar({editor}: ToolbarProps) {
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showColorPicker, setShowColorPicker] = useState(false);
const [selectedColor, setSelectedColor] = useState('#000000');
const [customColor, setCustomColor] = useState('');
const [showImageInput, setShowImageInput] = useState(false);
const [imageUrl, setImageUrl] = useState('');
// Factory function to create editor command handlers
const createCommandHandler = useCallback(
(command: (editor: Editor) => void) => () => {
if (!editor) return;
command(editor);
},
[editor],
);
// Complex handlers that need state management
const addLink = useCallback(() => {
if (!editor || !linkUrl) return;
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run();
} else {
editor.chain().focus().setLink({href: linkUrl}).run();
}
setLinkUrl('');
setShowLinkInput(false);
}, [editor, linkUrl]);
const removeLink = useCallback(() => {
if (!editor) return;
editor.chain().focus().unsetLink().run();
setLinkUrl('');
setShowLinkInput(false);
}, [editor]);
const setColor = useCallback(
(color: string) => {
if (!editor) return;
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
},
[editor],
);
const applyCustomColor = useCallback(() => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
setColor(customColor);
setCustomColor('');
setShowColorPicker(false);
}
}, [customColor, setColor]);
const toggleLinkInput = useCallback(() => {
if (!editor) return;
if (editor.isActive('link')) {
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}, [editor, showLinkInput]);
const addImage = useCallback(() => {
if (!editor || !imageUrl) return;
editor.chain().focus().setImage({src: imageUrl}).run();
setImageUrl('');
setShowImageInput(false);
}, [editor, imageUrl]);
if (!editor) {
return null;
}
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-40">
{/* History */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().undo().run())}
disabled={!editor.can().undo()}
className="h-8 w-8"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().redo().run())}
disabled={!editor.can().redo()}
className="h-8 w-8"
>
<Redo className="h-4 w-4" />
</Button>
</div>
{/* Text formatting */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBold().run())}
data-active={editor.isActive('bold')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleItalic().run())}
data-active={editor.isActive('italic')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleUnderline().run())}
data-active={editor.isActive('underline')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleStrike().run())}
data-active={editor.isActive('strike')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleCode().run())}
data-active={editor.isActive('code')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Code className="h-4 w-4" />
</Button>
</div>
{/* Headings */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 1}).run())}
data-active={editor.isActive('heading', {level: 1})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 2}).run())}
data-active={editor.isActive('heading', {level: 2})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleHeading({level: 3}).run())}
data-active={editor.isActive('heading', {level: 3})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading3 className="h-4 w-4" />
</Button>
</div>
{/* Lists */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBulletList().run())}
data-active={editor.isActive('bulletList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleOrderedList().run())}
data-active={editor.isActive('orderedList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().toggleBlockquote().run())}
data-active={editor.isActive('blockquote')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Quote className="h-4 w-4" />
</Button>
</div>
{/* Alignment */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('left').run())}
data-active={editor.isActive({textAlign: 'left'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('center').run())}
data-active={editor.isActive({textAlign: 'center'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('right').run())}
data-active={editor.isActive({textAlign: 'right'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={createCommandHandler(ed => ed.chain().focus().setTextAlign('justify').run())}
data-active={editor.isActive({textAlign: 'justify'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignJustify className="h-4 w-4" />
</Button>
</div>
{/* Color picker */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowColorPicker(!showColorPicker)}
className="h-8 w-8"
>
<Palette className="h-4 w-4" />
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
<div className="mb-3 pb-3 border-b border-neutral-200">
<label className="text-xs font-medium text-neutral-600 mb-1 block">Custom Color</label>
<div className="flex gap-2">
<Input
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value.toUpperCase())}
placeholder="#000000"
className="h-8 text-xs font-mono"
maxLength={7}
onKeyDown={e => {
if (e.key === 'Enter') {
applyCustomColor();
}
}}
/>
<Button
type="button"
size="sm"
onMouseDown={e => e.preventDefault()}
onClick={applyCustomColor}
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
className="h-8"
>
Apply
</Button>
</div>
</div>
{/* Color palette */}
<div className="space-y-3">
{EDITOR_COLOR_GROUPS.map(group => (
<div key={group.name}>
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
<div className="grid grid-cols-7 gap-1.5">
{group.colors.map(color => (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => {
setColor(color);
setShowColorPicker(false);
}}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-105 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Link */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={toggleLinkInput}
data-active={editor.isActive('link')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addLink();
} else if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addLink}>
{editor.isActive('link') ? 'Update' : 'Add'}
</Button>
</div>
{editor.isActive('link') && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="destructive"
onMouseDown={e => e.preventDefault()}
onClick={removeLink}
>
Remove Link
</Button>
</div>
)}
</div>
)}
</div>
{/* Image */}
<div className="relative">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowImageInput(!showImageInput)}
className="h-8 w-8"
>
<ImageIcon className="h-4 w-4" />
</Button>
{showImageInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="flex gap-2">
<input
type="url"
value={imageUrl}
onChange={e => setImageUrl(e.target.value)}
placeholder="https://example.com/image.jpg"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addImage();
} else if (e.key === 'Escape') {
setShowImageInput(false);
setImageUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addImage}>
Add
</Button>
</div>
</div>
)}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Color palette for the email editor toolbar
* Organized by hue using Tailwind color values
*/
export const EDITOR_COLOR_GROUPS = [
{
name: 'Neutrals',
colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'],
},
{
name: 'Reds',
colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'],
},
{
name: 'Oranges',
colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'],
},
{
name: 'Yellows',
colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'],
},
{
name: 'Greens',
colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'],
},
{
name: 'Blues',
colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'],
},
{
name: 'Purples',
colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'],
},
{
name: 'Pinks',
colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'],
},
] as const;
+169
View File
@@ -0,0 +1,169 @@
import juice from 'juice';
/**
* Converts modern HTML from Tiptap to email-friendly HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Preserves variable placeholders like {{email}}
*/
export function convertToEmailHtml(html: string): string {
// Wrap in email-safe container with basic styling
const wrappedHtml = `
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #374151;
margin: 0;
padding: 0;
}
h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px 0;
color: #111827;
}
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
color: #111827;
}
h3 {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
color: #111827;
}
p {
margin: 0 0 16px 0;
}
a {
color: #3B82F6;
text-decoration: underline;
}
ul, ol {
margin: 0 0 16px 0;
padding-left: 24px;
}
li {
margin-bottom: 8px;
}
blockquote {
margin: 0 0 16px 0;
padding-left: 16px;
border-left: 4px solid #E5E7EB;
color: #6B7280;
}
code {
background-color: #F3F4F6;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px 0;
}
th, td {
border: 1px solid #E5E7EB;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #F3F4F6;
font-weight: 600;
}
.variable-placeholder {
display: inline;
background-color: #DBEAFE;
color: #1E40AF;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3B82F6;
color: #FFFFFF;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 8px 0;
}
</style>
</head>
<body>
${html}
</body>
</html>
`;
// Inline CSS using juice
const inlined = juice(wrappedHtml, {
preserveMediaQueries: false,
preserveFontFaces: false,
removeStyleTags: true,
applyStyleTags: true,
});
// Extract just the body content
const bodyMatch = inlined.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined;
// Clean up Tiptap-specific artifacts
const cleaned = bodyContent
.replace(/\sdata-pm-slice="[^"]*"/g, '')
.replace(/\sclass=""/g, '')
.replace(/\sstyle=""/g, '');
return cleaned;
}
/**
* Wraps email fragment HTML in a complete HTML document structure
* This is what people expect when they want to send an email
*/
export function wrapEmailHtml(bodyContent: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email</title>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
/**
* Converts modern HTML from Tiptap to a complete, ready-to-send email HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Wraps in complete HTML document structure
* - Preserves variable placeholders like {{email}}
*/
export function convertToCompleteEmailHtml(html: string): string {
const fragment = convertToEmailHtml(html);
return wrapEmailHtml(fragment);
}
+27
View File
@@ -0,0 +1,27 @@
export interface EmailVerificationResult {
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> {
const response = await fetch('/api/verify-email', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({email}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Verification failed');
}
return response.json();
}
+37
View File
@@ -0,0 +1,37 @@
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
/**
* Educational content for the email verification tool
*/
export const EMAIL_VERIFICATION_FEATURES = [
{
title: 'Improve Deliverability',
description: 'Remove invalid emails before sending to reduce bounce rates and improve email deliverability.',
icon: Mail,
},
{
title: 'Catch Typos',
description: 'Detect common typos like "gmial.com" and suggest corrections to capture valid addresses.',
icon: Search,
},
{
title: 'Protect Reputation',
description: 'High bounce rates hurt your sender reputation. Verify emails to maintain a good standing.',
icon: Shield,
},
{
title: 'DNS Validation',
description: 'Check if the email domain exists and has properly configured MX records for receiving mail.',
icon: CheckCircle,
},
{
title: 'Disposable Detection',
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
icon: Mail,
},
{
title: 'Plus Addressing',
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
icon: Shield,
},
] as const;

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