Compare commits

..
152 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
Dries Augustyns dbf58046e7 Merge pull request #230 from useplunk/release-please--branches--next--components--plunk 2025-12-08 15:29:57 +01:00
github-actions[bot] d238965c2c chore(next): release 0.1.1 2025-12-08 14:29:25 +00:00
Dries Augustyns 53ecda9f7a fix: Add additional verification in Oauth controllers 2025-12-08 15:29:04 +01:00
Dries Augustyns fc535a558f fix: Dedicated token name for next version 2025-12-08 14:31:13 +01:00
Dries Augustyns f7ac25f91f docs: Update AWS setup docs 2025-12-08 14:23:08 +01:00
Dries Augustyns 9d8b4a59fb fix: only mark releases as latest 2025-12-08 13:26:41 +01:00
Dries Augustyns 60804c12b0 fix: add clear cache button on full-screen loader 2025-12-08 13:11:59 +01:00
Dries Augustyns 90f8170efb docs: dynamic link to Docker Compose for self-hosting 2025-12-08 12:39:09 +01:00
325 changed files with 29912 additions and 6829 deletions
+1
View File
@@ -57,6 +57,7 @@ vitest.config.ts
Dockerfile* Dockerfile*
docker docker
!docker/nginx !docker/nginx
!docker/*.sh
.dockerignore .dockerignore
# Misc # Misc
+33
View File
@@ -18,6 +18,20 @@ DASHBOARD_DOMAIN=app.example.com
LANDING_DOMAIN=www.example.com LANDING_DOMAIN=www.example.com
WIKI_DOMAIN=docs.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) # 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 # This affects how application URIs are auto-generated from domain names
USE_HTTPS=false USE_HTTPS=false
@@ -112,6 +126,25 @@ SMTP_DOMAIN=smtp.example.com
# Maximum recipients per email (default: 5) # Maximum recipients per email (default: 5)
# MAX_RECIPIENTS=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) # ADVANCED (rarely needed)
# ======================================== # ========================================
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
EOF EOF
- name: Build shared packages - 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 - name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF EOF
- name: Build shared packages - 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 - name: Run linter
run: yarn lint run: yarn lint
+4 -4
View File
@@ -62,7 +62,7 @@ jobs:
IMAGE="ghcr.io/${{ github.repository }}" IMAGE="ghcr.io/${{ github.repository }}"
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
# This is a release: use semver tags # This is a release: use semver tags + latest
TAG="${{ steps.check-release.outputs.release_tag }}" TAG="${{ steps.check-release.outputs.release_tag }}"
VERSION="${TAG#v}" VERSION="${TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1) MAJOR=$(echo "$VERSION" | cut -d. -f1)
@@ -70,9 +70,9 @@ jobs:
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest" TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
echo "Building RELEASE with tags: $TAGS" echo "Building RELEASE with tags: $TAGS"
else else
# Regular commit: use SHA tags # Regular commit: use SHA tags only (no latest)
SHORT_SHA="${GITHUB_SHA:0:7}" SHORT_SHA="${GITHUB_SHA:0:7}"
TAGS="sha-${SHORT_SHA},latest" TAGS="sha-${SHORT_SHA}"
echo "Building COMMIT with tags: $TAGS" echo "Building COMMIT with tags: $TAGS"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> $GITHUB_OUTPUT
@@ -158,7 +158,7 @@ jobs:
# Merge platform-specific images into multi-arch manifest # Merge platform-specific images into multi-arch manifest
merge: merge:
needs: [prepare, build] needs: [ prepare, build ]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.1.0" ".": "0.4.0"
} }
+126
View File
@@ -1,5 +1,131 @@
# Changelog # 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)
### Bug Fixes
* Add additional verification in Oauth controllers ([53ecda9](https://github.com/useplunk/plunk/commit/53ecda9f7a34111785ac3ea3af18cb33460a44af))
* add clear cache button on full-screen loader ([60804c1](https://github.com/useplunk/plunk/commit/60804c12b0b4c4c3ef97e730e4857d41fa1fd021))
* Dedicated token name for next version ([fc535a5](https://github.com/useplunk/plunk/commit/fc535a558fab28045dae9ce978f483e58c72a24f))
* only mark releases as latest ([9d8b4a5](https://github.com/useplunk/plunk/commit/9d8b4a59fbd42420bb595d2a44df93a29214121a))
### Documentation
* dynamic link to Docker Compose for self-hosting ([90f8170](https://github.com/useplunk/plunk/commit/90f8170efbad0cec981a24e7831840aac65d8d70))
* Update AWS setup docs ([f7ac25f](https://github.com/useplunk/plunk/commit/f7ac25f91f4e1dcce301fb325801a67f2a9dee07))
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08) ## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (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 - HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification) - Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues - **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com) - **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com) - **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
- **wiki**: Next.js app - Documentation site (docs.useplunk.com) - **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
### Background Job Architecture ### Background Job Architecture
@@ -108,6 +108,7 @@ between groups.
- Consistent type imports preferred: `import type { ... }` - Consistent type imports preferred: `import type { ... }`
- Unused vars allowed with `_` prefix - Unused vars allowed with `_` prefix
- Strict type checking enabled across all packages - Strict type checking enabled across all packages
- Try to avoid inline types in favor of shared types in `@plunk/types`
### Component Structure ### 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 (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
`STRIPE_METER_EVENT_NAME` `STRIPE_METER_EVENT_NAME`
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications) - Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
**Important Notes:** **Important Notes:**
@@ -154,3 +157,9 @@ Required for builds and deployment (see turbo.json and .env.example):
runtime runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for - **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access 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) - **api**: Express.js API server with background worker process (BullMQ)
- **web**: Next.js dashboard application (app.useplunk.com) - **web**: Next.js dashboard application (app.useplunk.com)
- **landing**: Next.js marketing site (www.useplunk.com) - **landing**: Next.js marketing site (next.useplunk.com)
- **wiki**: Next.js documentation site (docs.useplunk.com) - **wiki**: Next.js documentation site (next-wiki.useplunk.com)
### Shared Packages (`packages/`) ### Shared Packages (`packages/`)
@@ -135,7 +135,7 @@ between groups.
## Need Help? ## 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 - Open an issue for bugs or feature requests
- Join our community discussions - Join our community discussions
+34 -6
View File
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
# Build-time arguments for URL configuration # Build-time arguments for URL configuration
# These are only used during the build process (for wiki OpenAPI generation and static assets) # 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 # Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
ARG API_URI=https://api.useplunk.com ARG API_URI=https://next-api.useplunk.com
ARG DASHBOARD_URI=https://app.useplunk.com ARG DASHBOARD_URI=https://next-app.useplunk.com
ARG LANDING_URI=https://www.useplunk.com ARG LANDING_URI=https://next.useplunk.com
ARG WIKI_URI=https://docs.useplunk.com ARG WIKI_URI=https://next-wiki.useplunk.com
WORKDIR /app WORKDIR /app
@@ -123,6 +123,10 @@ COPY --from=deps /app/yarn.lock ./
# Copy root config files needed for Turbo # Copy root config files needed for Turbo
COPY turbo.json ./ 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) # Step 1: Copy and build shared packages (these change less frequently)
# Shared packages are dependencies for apps, so build them first # Shared packages are dependencies for apps, so build them first
COPY packages ./packages COPY packages ./packages
@@ -171,6 +175,10 @@ RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \ NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \ NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=wiki 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 # Step 4: Copy and build Web dashboard
COPY apps/web ./apps/web 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_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \ NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=web 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 # Step 5: Copy and build Landing page
COPY apps/landing ./apps/landing 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_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \ NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=landing 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 any remaining root files (if needed)
COPY . . 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/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/dist ./packages/email/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json 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/dist ./packages/types/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
# Copy Prisma schema (needed for migrations at runtime) # 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/.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/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 --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 # 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/.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/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 --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 # 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/.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/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/.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 --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) # 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 # 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/ COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
RUN chmod +x /app/docker/nginx/setup-nginx.sh 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 entrypoint script
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/ COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
+10 -6
View File
@@ -7,10 +7,14 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/> <a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
<img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml"/> <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>
<img src="https://img.shields.io/github/license/useplunk/plunk"/> <a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
<img src="https://img.shields.io/github/stars/useplunk/plunk"/> <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> </p>
## Introduction ## Introduction
@@ -40,14 +44,14 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk). You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
A complete guide on how to deploy Plunk can be found in A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/self-hosting/introduction). the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
## Contributing ## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md). 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"> <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> </a>
## License ## 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=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) 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) # 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_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_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) 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/shared": "*",
"@plunk/types": "*", "@plunk/types": "*",
"@react-email/render": "^2.0.0", "@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"bullmq": "^5.63.2", "bullmq": "^5.63.2",
@@ -34,6 +35,7 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.8.2", "ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^2.0.2", "multer": "^2.0.2",
"signale": "^1.4.0", "signale": "^1.4.0",
@@ -10,7 +10,7 @@ import {
NotAuthenticated, NotAuthenticated,
NotFound, NotFound,
RateLimitError, RateLimitError,
ValidationError ValidationError,
} from '../../exceptions/index.js'; } from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.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', () => { it('should validate subject and body required when no template', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
from: '[email protected]',
// Missing subject, body, and template // Missing subject, body, and template
}); });
@@ -169,6 +170,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as string (backward compatible)', () => { it('should accept to as string (backward compatible)', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
}); });
@@ -182,6 +184,7 @@ describe('Actions API Integration Tests', () => {
name: 'Jane Doe', name: 'Jane Doe',
email: '[email protected]', email: '[email protected]',
}, },
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
}); });
@@ -194,6 +197,7 @@ describe('Actions API Integration Tests', () => {
to: { to: {
email: '[email protected]', email: '[email protected]',
}, },
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
}); });
@@ -204,6 +208,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as array of strings', () => { it('should accept to as array of strings', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: ['[email protected]', '[email protected]'], to: ['[email protected]', '[email protected]'],
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
}); });
@@ -217,6 +222,7 @@ describe('Actions API Integration Tests', () => {
{name: 'Jane Doe', email: '[email protected]'}, {name: 'Jane Doe', email: '[email protected]'},
{name: 'John Smith', email: '[email protected]'}, {name: 'John Smith', email: '[email protected]'},
], ],
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
}); });
@@ -227,6 +233,7 @@ describe('Actions API Integration Tests', () => {
it('should accept to as mixed array of strings and objects', () => { it('should accept to as mixed array of strings and objects', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}], to: ['[email protected]', {name: 'John Smith', email: '[email protected]'}],
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: '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 {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
// Note: To run these integration tests, you need to: // Note: To run these integration tests, you need to:
+94 -10
View File
@@ -14,6 +14,7 @@ import {
GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED,
LANDING_URI, LANDING_URI,
NODE_ENV, NODE_ENV,
PLUNK_ENABLED,
PORT, PORT,
S3_ENABLED, S3_ENABLED,
SMTP_ENABLED, SMTP_ENABLED,
@@ -75,23 +76,73 @@ const server = new (class extends Server {
// Log all requests to database for historical tracking and analytics // Log all requests to database for historical tracking and analytics
this.app.use(databaseRequestLogger); 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 // Build allowed origins from environment variables
const allowedOrigins = const allowedOrigins =
NODE_ENV === 'development' NODE_ENV === 'development'
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000'] ? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
: [DASHBOARD_URI, LANDING_URI, WIKI_URI]; : [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({ 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, credentials: true,
}), })(req, res, next);
); });
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short')); 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) => { server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
const requestId = res.locals.requestId as string | undefined; 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 // Handle Zod validation errors
if (error instanceof ZodError) { if (error instanceof ZodError) {
const fieldErrors: FieldError[] = error.errors.map(err => ({ 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 // Global error handlers to prevent server crashes
process.on('unhandledRejection', (reason, promise) => { process.on('unhandledRejection', (reason, promise) => {
signale.error('Unhandled Promise Rejection:', reason); signale.error('Unhandled Promise Rejection:', reason);
console.error('Promise:', promise); signale.error('Promise:', promise);
// Don't exit the process - just log the error // Don't exit the process - just log the error
}); });
@@ -351,6 +430,11 @@ void prisma.$connect().then(async () => {
? 'Per-project tracking toggle enabled' ? 'Per-project tracking toggle enabled'
: 'Always tracking or always no-tracking', : '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 => ({ 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_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY'); 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 // Storage
export const REDIS_URL = validateEnv('REDIS_URL'); export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_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 // Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
export const SMTP_ENABLED = export const SMTP_ENABLED =
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development'); 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 {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared'; import {ActionSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js'; import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js'; import {EmailService} from '../services/EmailService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {EventService} from '../services/EventService.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 {CatchAsync} from '../utils/asyncHandler.js';
import {DASHBOARD_URI} from '../app/constants.js';
/** /**
* Public API Actions Controller * Public API Actions Controller
* Handles track event and transactional email endpoints * Handles track event, transactional email, and email verification endpoints
*/ */
@Controller('v1') @Controller('v1')
export class Actions { export class Actions {
@@ -25,7 +25,7 @@ export class Actions {
* Request body: * Request body:
* - event: string (required) - Event name * - event: string (required) - Event name
* - email: string (required) - Contact email * - 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 * - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent) * - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent) * - {value: any, persistent: false} are only available to workflows (non-persistent)
@@ -50,11 +50,26 @@ export class Actions {
@Middleware([requirePublicKey]) @Middleware([requirePublicKey])
@CatchAsync @CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) { 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 // Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body); 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 // Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields // ContactService.upsert will filter out non-persistent fields
const contact = await ContactService.upsert( const contact = await ContactService.upsert(
@@ -95,7 +110,7 @@ export class Actions {
* - Array: ["[email protected]", {name: "Jane", email: "[email protected]"}] * - Array: ["[email protected]", {name: "Jane", email: "[email protected]"}]
* - subject: string (required) - Email subject * - subject: string (required) - Email subject
* - body: string (required) - Email HTML body * - 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) * - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain) * - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email * - reply: string (optional) - Reply-to email
@@ -156,7 +171,7 @@ export class Actions {
@Middleware([requireSecretKey]) @Middleware([requireSecretKey])
@CatchAsync @CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) { 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 // Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} = const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
@@ -223,14 +238,21 @@ export class Actions {
templateId = templateRecord.id; templateId = templateRecord.id;
} }
// Verify 'from' domain is verified if provided if (!emailFrom) {
const senderEmail = emailFrom || '[email protected]'; // Default sender throw new ValidationError(
[
// Only verify custom domains (not the default [email protected]) {
if (emailFrom && emailFrom !== '[email protected]') { field: 'from',
await DomainService.verifyEmailDomain(emailFrom, auth.projectId); 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 replyToEmail = emailReplyTo;
const timestamp = new Date(); const timestamp = new Date();
@@ -249,12 +271,24 @@ export class Actions {
// Get merged data including non-persistent fields for template rendering // Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined); const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
// 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 // Render template with contact data
// Simple template variable replacement: {{fieldname}} // Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!; let renderedSubject = emailSubject!;
let renderedBody = emailBody!; 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 placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g'); const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
@@ -281,7 +315,7 @@ export class Actions {
contactId: contact.id, contactId: contact.id,
subject: renderedSubject, subject: renderedSubject,
body: renderedBody, body: renderedBody,
from: senderEmail, from: emailFrom,
fromName: emailFromName, fromName: emailFromName,
toName: recipient.name, toName: recipient.name,
replyTo: replyToEmail, 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 {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import {ActivityType} from '@plunk/types';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js'; import {ActivityService} from '../services/ActivityService.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity') @Controller('activity')
@@ -21,10 +20,10 @@ export class Activity {
* - endDate: ISO date string * - endDate: ISO date string
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined; const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId 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) * - endDate: ISO date string (defaults to now)
*/ */
@Get('stats') @Get('stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) { 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 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; const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -82,10 +81,10 @@ export class Activity {
* - minutes: number (default 5) * - minutes: number (default 5)
*/ */
@Get('recent-count') @Get('recent-count')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) { 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 minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes); const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
@@ -98,7 +97,7 @@ export class Activity {
* Get available activity types (for UI filters) * Get available activity types (for UI filters)
*/ */
@Get('types') @Get('types')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) { public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType); const types = Object.values(ActivityType);
@@ -114,10 +113,10 @@ export class Activity {
* - daysAhead: number (default 30, max 90) * - daysAhead: number (default 30, max 90)
*/ */
@Get('upcoming') @Get('upcoming')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90); 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 {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js'; import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,10 +17,10 @@ export class Analytics {
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered) * Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/ */
@Get('timeseries') @Get('timeseries')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) { 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 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; 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) * - endDate: ISO date string (defaults to now)
*/ */
@Get('top-campaigns') @Get('top-campaigns')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) { 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 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 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; 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 * Returns aggregate stats: total campaigns, active, completed, average rates
*/ */
@Get('campaign-stats') @Get('campaign-stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) { 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 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; 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 * Returns events sorted by frequency with trend data
*/ */
@Get('top-events') @Get('top-events')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) { 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 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 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; 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 {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared'; 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 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 {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.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 {AuthService} from '../services/AuthService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js'; import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js'; import {Keys} from '../services/keys.js';
@@ -50,6 +65,31 @@ export class Auth {
public async signup(req: Request, res: Response, _next: NextFunction) { public async signup(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body); 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); const user = await UserService.email(email);
if (user) { if (user) {
@@ -64,6 +104,8 @@ export class Auth {
email, email,
password: await AuthService.generateHash(password), password: await AuthService.generateHash(password),
type: '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 // Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id); 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 token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions(); 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 {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; 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 type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -17,14 +16,13 @@ export class Campaigns {
* POST /campaigns * POST /campaigns
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) { 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} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body); CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) { if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
} }
@@ -60,10 +58,10 @@ export class Campaigns {
* GET /campaigns * GET /campaigns
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) { 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 status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20; const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -79,13 +77,7 @@ export class Campaigns {
pageSize, pageSize,
}); });
return res.json({ return res.json(result);
campaigns: result.campaigns,
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
});
} }
/** /**
@@ -93,11 +85,11 @@ export class Campaigns {
* GET /campaigns/:id * GET /campaigns/:id
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) { private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!); const campaign = await CampaignService.get(auth.projectId, id!);
@@ -112,11 +104,11 @@ export class Campaigns {
* PUT /campaigns/:id * PUT /campaigns/:id
*/ */
@Put(':id') @Put(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) { private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body; req.body;
@@ -158,11 +150,11 @@ export class Campaigns {
* DELETE /campaigns/:id * DELETE /campaigns/:id
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) { private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!); await CampaignService.delete(auth.projectId, id!);
@@ -177,11 +169,11 @@ export class Campaigns {
* POST /campaigns/:id/duplicate * POST /campaigns/:id/duplicate
*/ */
@Post(':id/duplicate') @Post(':id/duplicate')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) { private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!); const campaign = await CampaignService.duplicate(auth.projectId, id!);
@@ -197,11 +189,11 @@ export class Campaigns {
* POST /campaigns/:id/send * POST /campaigns/:id/send
*/ */
@Post(':id/send') @Post(':id/send')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) { private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor; const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided // Parse scheduledFor if provided
@@ -228,11 +220,11 @@ export class Campaigns {
* POST /campaigns/:id/cancel * POST /campaigns/:id/cancel
*/ */
@Post(':id/cancel') @Post(':id/cancel')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) { private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!); const campaign = await CampaignService.cancel(auth.projectId, id!);
@@ -248,11 +240,11 @@ export class Campaigns {
* GET /campaigns/:id/stats * GET /campaigns/:id/stats
*/ */
@Get(':id/stats') @Get(':id/stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) { private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!); const stats = await CampaignService.getStats(auth.projectId, id!);
@@ -267,11 +259,11 @@ export class Campaigns {
* POST /campaigns/:id/test * POST /campaigns/:id/test
*/ */
@Post(':id/test') @Post(':id/test')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) { private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body); const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email); 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 {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import multer from 'multer'; import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js'; import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,7 +14,6 @@ const upload = multer({
fileSize: 5 * 1024 * 1024, // 5MB max file size fileSize: 5 * 1024 * 1024, // 5MB max file size
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) { if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true); cb(null, true);
} else { } else {
@@ -31,10 +29,10 @@ export class Contacts {
* List all contacts for the authenticated project with cursor-based pagination * List all contacts for the authenticated project with cursor-based pagination
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined; const cursor = req.query.cursor as string | undefined;
const search = req.query.search 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) * Returns field names with inferred types (string, number, boolean, date)
*/ */
@Get('fields') @Get('fields')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
try { try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!); const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
@@ -63,7 +61,7 @@ export class Contacts {
count: fieldsWithTypes.length, count: fieldsWithTypes.length,
}); });
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields', 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 * Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/ */
@Get('fields/:field/values') @Get('fields/:field/values')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) { 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 field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200); const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
@@ -97,7 +95,7 @@ export class Contacts {
limit, limit,
}); });
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values', 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 a specific contact by ID
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const contactId = req.params.id;
if (!contactId) { if (!contactId) {
@@ -129,10 +127,10 @@ export class Contacts {
* Create or update a contact (upsert) * Create or update a contact (upsert)
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {email, data, subscribed} = req.body; const {email, data, subscribed} = req.body;
if (!email) { if (!email) {
@@ -159,10 +157,10 @@ export class Contacts {
* Update a contact * Update a contact
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 contactId = req.params.id;
const {email, data, subscribed} = req.body; const {email, data, subscribed} = req.body;
@@ -180,10 +178,10 @@ export class Contacts {
* Delete a contact * Delete a contact
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const contactId = req.params.id;
if (!contactId) { if (!contactId) {
@@ -210,10 +208,24 @@ export class Contacts {
const contact = await ContactService.getById(contactId); 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({ return res.status(200).json({
id: contact.id, id: contact.id,
email: contact.email, email: contact.email,
subscribed: contact.subscribed, subscribed: contact.subscribed,
language: contactLocale || project?.language || 'en',
}); });
} }
@@ -269,7 +281,7 @@ export class Contacts {
@Middleware([requireAuth, upload.single('file')]) @Middleware([requireAuth, upload.single('file')])
@CatchAsync @CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) { public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!req.file) { if (!req.file) {
return res.status(400).json({error: 'CSV file is required'}); return res.status(400).json({error: 'CSV file is required'});
@@ -288,7 +300,7 @@ export class Contacts {
jobId: job.id, jobId: job.id,
}); });
} catch (error) { } catch (error) {
console.error('[CONTACTS] Failed to queue import:', error); signale.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import', error: error instanceof Error ? error.message : 'Failed to queue import',
}); });
@@ -300,7 +312,7 @@ export class Contacts {
* Get import job status * Get import job status
*/ */
@Get('import/:jobId') @Get('import/:jobId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) { public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId; const jobId = req.params.jobId;
@@ -318,7 +330,7 @@ export class Contacts {
return res.status(200).json(status); return res.status(200).json(status);
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status', 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 * Returns information about where the field is used and whether it can be safely deleted
*/ */
@Get('fields/:field/usage') @Get('fields/:field/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) { 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; const field = req.params.field;
if (!field) { if (!field) {
@@ -345,7 +357,7 @@ export class Contacts {
const usage = await ContactService.getFieldUsage(auth.projectId!, field); const usage = await ContactService.getFieldUsage(auth.projectId!, field);
return res.status(200).json(usage); return res.status(200).json(usage);
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field usage', 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 * Only works if the field is not used in any segments or campaigns
*/ */
@Delete('fields/:field') @Delete('fields/:field')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) { 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; const field = req.params.field;
if (!field) { if (!field) {
@@ -372,10 +384,143 @@ export class Contacts {
const result = await ContactService.deleteField(auth.projectId!, field); const result = await ContactService.deleteField(auth.projectId!, field);
return res.status(200).json(result); return res.status(200).json(result);
} catch (error) { } 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({ return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete field', 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 {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js'; import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js'; import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.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'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains') @Controller('domains')
@@ -17,23 +16,14 @@ export class Domains {
* Get all domains for a project * Get all domains for a project
*/ */
@Get('project/:projectId') @Get('project/:projectId')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) { 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); const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, projectId);
where: {
userId: auth.userId,
projectId,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have access');
}
const domains = await DomainService.getProjectDomains(projectId); const domains = await DomainService.getProjectDomains(projectId);
@@ -44,10 +34,10 @@ export class Domains {
* Add a new domain to a project * Add a new domain to a project
*/ */
@Post('') @Post('')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) { 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); const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) { if (!auth.userId) {
@@ -55,19 +45,7 @@ export class Domains {
} }
// Verify user has admin access to this project // Verify user has admin access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, projectId);
where: {
userId: auth.userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission');
}
// Check if domain is already linked to another project // Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId); const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
@@ -104,10 +82,10 @@ export class Domains {
* Check verification status for a domain * Check verification status for a domain
*/ */
@Get(':id/verify') @Get(':id/verify')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id); const domain = await DomainService.id(id);
@@ -117,16 +95,7 @@ export class Domains {
} }
// Verify user has access to the project this domain belongs to // Verify user has access to the project this domain belongs to
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, domain.projectId);
where: {
userId: auth.userId,
projectId: domain.projectId,
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have access');
}
const verificationStatus = await DomainService.checkVerification(id); const verificationStatus = await DomainService.checkVerification(id);
@@ -141,10 +110,10 @@ export class Domains {
* Remove a domain from a project * Remove a domain from a project
*/ */
@Delete(':id') @Delete(':id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) { 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 {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id); 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 // Verify user has admin access to the project this domain belongs to
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
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 DomainService.removeDomain(id); await DomainService.removeDomain(id);
+18 -19
View File
@@ -1,8 +1,7 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,10 +12,10 @@ export class Events {
* Track a custom event (can trigger workflows) * Track a custom event (can trigger workflows)
*/ */
@Post('track') @Post('track')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) { 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; const {name, contactId, emailId, data} = req.body;
if (!name) { if (!name) {
@@ -33,10 +32,10 @@ export class Events {
* List events for the project * List events for the project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100; const limit = parseInt(req.query.limit as string) || 100;
@@ -50,10 +49,10 @@ export class Events {
* Get event statistics * Get event statistics
*/ */
@Get('stats') @Get('stats')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) { 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 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; 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 events for a specific contact
*/ */
@Get('contact/:contactId') @Get('contact/:contactId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) { 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 contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50; const limit = parseInt(req.query.limit as string) || 50;
@@ -88,10 +87,10 @@ export class Events {
* Get unique event names for the project * Get unique event names for the project
*/ */
@Get('names') @Get('names')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) { 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!); 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 * Returns information about where the event is used and whether it can be safely deleted
*/ */
@Get(':eventName/usage') @Get(':eventName/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.params.eventName;
if (!eventName) { if (!eventName) {
@@ -118,7 +117,7 @@ export class Events {
const usage = await EventService.getEventUsage(auth.projectId!, eventName); const usage = await EventService.getEventUsage(auth.projectId!, eventName);
return res.status(200).json(usage); return res.status(200).json(usage);
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get event usage', 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 * Only works if the event is not used in any segments or workflows
*/ */
@Delete(':eventName') @Delete(':eventName')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.params.eventName;
if (!eventName) { if (!eventName) {
@@ -145,7 +144,7 @@ export class Events {
const result = await EventService.deleteEvent(auth.projectId!, eventName); const result = await EventService.deleteEvent(auth.projectId!, eventName);
return res.status(200).json(result); return res.status(200).json(result);
} catch (error) { } 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({ return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete event', error: error instanceof Error ? error.message : 'Failed to delete event',
}); });
+23 -4
View File
@@ -40,6 +40,10 @@ export class Github {
} }
const {code} = req.query; const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({ const data = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT, client_id: GITHUB_OAUTH_CLIENT,
client_secret: GITHUB_OAUTH_SECRET, client_secret: GITHUB_OAUTH_SECRET,
@@ -47,19 +51,33 @@ export class Github {
redirect_uri: `${API_URI}/oauth/github/callback`, redirect_uri: `${API_URI}/oauth/github/callback`,
}); });
const {access_token, token_type} = await fetch('https://github.com/login/oauth/access_token', { const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST', method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'}, headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
body: data, body: data,
}).then(res => res.json()); }).then(res => res.json());
if (!tokenResponse.access_token || !tokenResponse.token_type) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
}
const emails = await fetch(`https://api.github.com/user/emails`, { const emails = await fetch(`https://api.github.com/user/emails`, {
headers: {Authorization: `${token_type} ${access_token}`}, headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
}).then(res => res.json()); }).then(res => res.json());
const email = emails.find((e: {primary: boolean; email: string}) => e.primary).email; if (!Array.isArray(emails) || emails.length === 0) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
}
let user = await UserService.email(email as string); const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
}
const email = primaryEmail.email;
let user = await UserService.email(email);
let isNewUser = false; let isNewUser = false;
if (!user) { if (!user) {
@@ -67,6 +85,7 @@ export class Github {
data: { data: {
email, email,
type: 'GITHUB_OAUTH', type: 'GITHUB_OAUTH',
emailVerified: true,
}, },
}); });
isNewUser = true; isNewUser = true;
+19 -4
View File
@@ -35,6 +35,10 @@ export class Google {
} }
const {code} = req.query; const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({ const data = new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT, client_id: GOOGLE_OAUTH_CLIENT,
client_secret: GOOGLE_OAUTH_SECRET, client_secret: GOOGLE_OAUTH_SECRET,
@@ -43,15 +47,25 @@ export class Google {
grant_type: 'authorization_code', grant_type: 'authorization_code',
}); });
const {access_token} = await fetch('https://oauth2.googleapis.com/token', { const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'}, headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: data, body: data,
}).then(res => res.json()); }).then(res => res.json());
const {email} = await fetch(`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${access_token}`).then( if (!tokenResponse.access_token) {
res => res.json(), return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
); }
const userInfoResponse = await fetch(
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
).then(res => res.json());
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
}
const email = userInfoResponse.email;
let user = await UserService.email(email); let user = await UserService.email(email);
let isNewUser = false; let isNewUser = false;
@@ -61,6 +75,7 @@ export class Google {
data: { data: {
email, email,
type: 'GOOGLE_OAUTH', type: 'GOOGLE_OAUTH',
emailVerified: true,
}, },
}); });
isNewUser = true; isNewUser = true;
+53 -172
View File
@@ -1,11 +1,12 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; 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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js'; import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('projects') @Controller('projects')
@@ -15,23 +16,14 @@ export class Projects {
* GET /projects/:id/setup-state * GET /projects/:id/setup-state
*/ */
@Get(':id/setup-state') @Get(':id/setup-state')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) { private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Get project with relevant data // Get project with relevant data
const project = await prisma.project.findUnique({ 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 all members of a project
* GET /projects/:id/members * GET /projects/:id/members
*/ */
@Get(':id/members') @Get(':id/members')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) { private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Get all members of the project // Get all members of the project
const members = await prisma.membership.findMany({ const members = await MembershipService.getMembers(id);
where: {
projectId: id,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
return res.json({ return res.json({
success: true, success: true,
data: members.map(m => ({ data: members,
userId: m.user.id,
email: m.user.email,
role: m.role,
})),
}); });
} }
@@ -137,11 +127,11 @@ export class Projects {
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' } * Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/ */
@Post(':id/members') @Post(':id/members')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) { private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Validate params // Validate params
if (!id) { if (!id) {
@@ -157,19 +147,7 @@ export class Projects {
const {email, role} = parseResult.data; const {email, role} = parseResult.data;
// Verify current user is ADMIN or OWNER // Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can add members');
}
// Find user by email // Find user by email
const userToAdd = await prisma.user.findUnique({ 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'); throw new HttpException(404, 'User with this email does not have an account');
} }
// Check if user is already a member // Add member to project
const existingMembership = await prisma.membership.findUnique({ const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
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,
},
});
return res.json({ return res.json({
success: true, success: true,
@@ -220,10 +178,10 @@ export class Projects {
* Body: { role: 'ADMIN' | 'MEMBER' } * Body: { role: 'ADMIN' | 'MEMBER' }
*/ */
@Patch(':id/members/:userId') @Patch(':id/members/:userId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) { 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; const {id, userId} = req.params;
// Validate params // Validate params
@@ -243,38 +201,7 @@ export class Projects {
const {role} = parseResult.data; const {role} = parseResult.data;
// Verify current user is ADMIN or OWNER // Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Get user info // Get user info
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -286,16 +213,8 @@ export class Projects {
throw new HttpException(404, 'User not found'); throw new HttpException(404, 'User not found');
} }
// Update role // Update role (service handles validation)
await prisma.membership.update({ await MembershipService.updateRole(id, userId, role);
where: {
userId_projectId: {
userId,
projectId: id,
},
},
data: {role},
});
return res.json({ return res.json({
success: true, success: true,
@@ -312,10 +231,10 @@ export class Projects {
* DELETE /projects/:id/members/:userId * DELETE /projects/:id/members/:userId
*/ */
@Delete(':id/members/:userId') @Delete(':id/members/:userId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) { 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; const {id, userId} = req.params;
// Validate params // Validate params
@@ -327,53 +246,15 @@ export class Projects {
} }
// Verify current user is ADMIN or OWNER // Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Cannot remove yourself // Cannot remove yourself
if (userId === auth.userId) { if (userId === auth.userId) {
throw new HttpException(403, 'You cannot remove yourself from the project'); throw new HttpException(403, 'You cannot remove yourself from the project');
} }
// Delete membership // Remove member (service handles validation)
await prisma.membership.delete({ await MembershipService.removeMember(id, userId);
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
return res.json({ return res.json({
success: true, success: true,
+17 -19
View File
@@ -1,8 +1,6 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js'; import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -13,10 +11,10 @@ export class Segments {
* List all segments for the authenticated project * List all segments for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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!); const segments = await SegmentService.list(auth.projectId!);
@@ -28,10 +26,10 @@ export class Segments {
* Get a specific segment by ID with member count * Get a specific segment by ID with member count
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -48,10 +46,10 @@ export class Segments {
* Get contacts that match a segment's filters * Get contacts that match a segment's filters
*/ */
@Get(':id/contacts') @Get(':id/contacts')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) { 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 segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -70,10 +68,10 @@ export class Segments {
* Create a new segment * Create a new segment
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {name, description, condition, trackMembership} = req.body; const {name, description, condition, trackMembership} = req.body;
if (!name) { if (!name) {
@@ -99,10 +97,10 @@ export class Segments {
* Update a segment * Update a segment
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body; const {name, description, condition, trackMembership} = req.body;
@@ -129,10 +127,10 @@ export class Segments {
* Delete a segment * Delete a segment
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -149,10 +147,10 @@ export class Segments {
* Recompute segment membership for all contacts * Recompute segment membership for all contacts
*/ */
@Post(':id/compute') @Post(':id/compute')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
@@ -169,10 +167,10 @@ export class Segments {
* Refresh segment member count * Refresh segment member count
*/ */
@Post(':id/refresh') @Post(':id/refresh')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) { 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; const segmentId = req.params.id;
if (!segmentId) { if (!segmentId) {
+15 -17
View File
@@ -1,9 +1,7 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db'; import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js'; import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js'; import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,10 +13,10 @@ export class Templates {
* List all templates for the authenticated project * List all templates for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined; const search = req.query.search as string | undefined;
@@ -34,10 +32,10 @@ export class Templates {
* Get a specific template by ID * Get a specific template by ID
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -54,10 +52,10 @@ export class Templates {
* Create a new template * Create a new template
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body; const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) { if (!name) {
@@ -98,10 +96,10 @@ export class Templates {
* Update a template * Update a template
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body; const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
@@ -133,10 +131,10 @@ export class Templates {
* Delete a template * Delete a template
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -153,10 +151,10 @@ export class Templates {
* Duplicate a template * Duplicate a template
*/ */
@Post(':id/duplicate') @Post(':id/duplicate')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
@@ -173,10 +171,10 @@ export class Templates {
* Get template usage statistics * Get template usage statistics
*/ */
@Get(':id/usage') @Get(':id/usage')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) { 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; const templateId = req.params.id;
if (!templateId) { if (!templateId) {
+5 -7
View File
@@ -1,9 +1,8 @@
import {Controller, Middleware, Post} from '@overnightjs/core'; import {Controller, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import multer from 'multer'; import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js'; import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,7 +13,6 @@ const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max file size fileSize: 10 * 1024 * 1024, // 10MB max file size
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) { if (allowedMimeTypes.includes(file.mimetype)) {
@@ -32,10 +30,10 @@ export class Uploads {
* Upload an image file to S3/Minio * Upload an image file to S3/Minio
*/ */
@Post('image') @Post('image')
@Middleware([requireAuth, upload.single('image')]) @Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync @CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) { public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
try { try {
if (!S3Service.isS3Enabled()) { if (!S3Service.isS3Enabled()) {
@@ -66,7 +64,7 @@ export class Uploads {
size: req.file.size, size: req.file.size,
}); });
} catch (error) { } catch (error) {
console.error('[UPLOADS] Failed to upload image:', error); signale.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image', error: error instanceof Error ? error.message : 'Failed to upload image',
}); });
+114 -181
View File
@@ -1,16 +1,16 @@
import {randomBytes} from 'node:crypto'; import {randomBytes} from 'node:crypto';
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core'; 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 type {NextFunction, Request, Response} from 'express';
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js'; import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
import {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {NotAuthenticated, NotFound} from '../exceptions/index.js'; import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js'; import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {isAuthenticated} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js'; import {BillingLimitService} from '../services/BillingLimitService.js';
import {MembershipService} from '../services/MembershipService.js';
import {NtfyService} from '../services/NtfyService.js'; import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js'; import {UserService} from '../services/UserService.js';
@@ -20,10 +20,10 @@ import signale from 'signale';
@Controller('users') @Controller('users')
export class Users { export class Users {
@Get('@me') @Get('@me')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) { public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -39,10 +39,10 @@ export class Users {
} }
@Get('@me/projects') @Get('@me/projects')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) { public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -54,15 +54,25 @@ export class Users {
} }
@Post('@me/projects') @Post('@me/projects')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) { public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); 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); const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys // Generate unique API keys
@@ -91,27 +101,15 @@ export class Users {
} }
@Patch('@me/projects/:id') @Patch('@me/projects/:id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) { public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body); const data = ProjectSchemas.update.parse(req.body);
// Verify user has access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Update the project // Update the project
const project = await prisma.project.update({ const project = await prisma.project.update({
@@ -123,26 +121,14 @@ export class Users {
} }
@Post('@me/projects/:id/regenerate-keys') @Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) { public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Generate new unique API keys // Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`; const publicKey = `pk_${randomBytes(32).toString('hex')}`;
@@ -175,31 +161,20 @@ export class Users {
} }
@Post('@me/projects/:id/checkout') @Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) { public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
// Check if billing is enabled // Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) { if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'}); return res.status(404).json({error: 'Billing is not enabled'});
} }
// Verify user has access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Get the project // Get the project
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
@@ -235,6 +210,17 @@ export class Users {
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1); const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000); 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 // Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included // 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 // 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 customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems, line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
subscription_data: { subscription_data: {
billing_cycle_anchor: billingCycleAnchor, billing_cycle_anchor: billingCycleAnchor,
}, },
@@ -254,31 +241,19 @@ export class Users {
} }
@Post('@me/projects/:id/billing-portal') @Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) { public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) { if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'}); return res.status(404).json({error: 'Billing is not enabled'});
} }
// Verify user has access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
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');
}
// Get the project // Get the project
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
@@ -304,11 +279,11 @@ export class Users {
} }
@Get('@me/projects/:id/billing-limits') @Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) { public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -319,16 +294,7 @@ export class Users {
} }
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing limits');
}
// Get billing limits and usage // Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id); const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -337,11 +303,11 @@ export class Users {
} }
@Put('@me/projects/:id/billing-limits') @Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) { public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -354,25 +320,16 @@ export class Users {
const data = BillingLimitSchemas.update.parse(req.body); const data = BillingLimitSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) { // Get the project with current limits
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
// Get the project
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {id}, where: {id},
select: { select: {
subscription: true, subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
}, },
}); });
@@ -395,8 +352,17 @@ export class Users {
}, },
}); });
// Invalidate cache so new limits take effect immediately // Clear notification cache keys for limits that changed
await BillingLimitService.invalidateCache(id); // 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); const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -404,11 +370,11 @@ export class Users {
} }
@Get('@me/projects/:id/billing-consumption') @Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) { public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) { if (!STRIPE_ENABLED || !stripe) {
@@ -424,16 +390,7 @@ export class Users {
} }
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {id}, where: {id},
@@ -530,11 +487,11 @@ export class Users {
} }
@Get('@me/projects/:id/billing-invoices') @Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) { public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) { if (!STRIPE_ENABLED || !stripe) {
@@ -550,16 +507,7 @@ export class Users {
} }
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
// Get the project // Get the project
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
@@ -618,11 +566,11 @@ export class Users {
} }
@Get('@me/projects/:id/security') @Get('@me/projects/:id/security')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) { public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -633,16 +581,7 @@ export class Users {
} }
// Verify user has access to this project // Verify user has access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view security metrics');
}
// Get security metrics // Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id); const metrics = await SecurityService.getProjectSecurityMetrics(id);
@@ -651,11 +590,11 @@ export class Users {
} }
@Post('@me/projects/:id/reset') @Post('@me/projects/:id/reset')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) { public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -666,18 +605,16 @@ export class Users {
} }
// Verify user has admin/owner access to this project // Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) { // Check if project is disabled - block reset operation
throw new NotFound('Project not found or you do not have permission to reset it'); 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 // Delete all project data in a transaction
@@ -727,11 +664,11 @@ export class Users {
} }
@Delete('@me/projects/:id') @Delete('@me/projects/:id')
@Middleware([isAuthenticated]) @Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) { public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {id} = req.params; const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -742,29 +679,16 @@ export class Users {
} }
// Verify user has owner or admin access to this project // Verify user has owner or admin access to this project
const membership = await prisma.membership.findFirst({ await MembershipService.requireAdminAccess(auth.userId!, id);
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['OWNER', 'ADMIN'],
},
},
});
if (!membership) { // Get project to check for active subscription and disabled status
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
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
where: {id}, where: {id},
select: { select: {
name: true, name: true,
subscription: true, subscription: true,
customer: true, customer: true,
disabled: true,
}, },
}); });
@@ -772,6 +696,15 @@ export class Users {
throw new NotFound('Project not found'); 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 project has an active subscription, cancel it first
if (STRIPE_ENABLED && stripe && project.subscription) { if (STRIPE_ENABLED && stripe && project.subscription) {
try { try {
+35 -36
View File
@@ -1,9 +1,8 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core'; import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db'; import {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js'; import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -14,10 +13,10 @@ export class Workflows {
* List all workflows for the authenticated project * List all workflows for the authenticated project
*/ */
@Get('') @Get('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) { 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 page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined; 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 * NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/ */
@Get('fields') @Get('fields')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) { 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; const eventName = req.query.eventName as string | undefined;
try { try {
@@ -45,7 +44,7 @@ export class Workflows {
return res.status(200).json(result); return res.status(200).json(result);
} catch (error) { } 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({ return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields', 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 a specific workflow with all steps and transitions
*/ */
@Get(':id') @Get(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { if (!workflowId) {
@@ -77,10 +76,10 @@ export class Workflows {
* Create a new workflow * Create a new workflow
*/ */
@Post('') @Post('')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) { public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {name, description, eventName, enabled, allowReentry} = req.body; const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) { if (!name) {
@@ -107,10 +106,10 @@ export class Workflows {
* Update a workflow * Update a workflow
*/ */
@Patch(':id') @Patch(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body; const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
@@ -135,10 +134,10 @@ export class Workflows {
* Delete a workflow * Delete a workflow
*/ */
@Delete(':id') @Delete(':id')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { if (!workflowId) {
@@ -155,10 +154,10 @@ export class Workflows {
* Add a step to a workflow * Add a step to a workflow
*/ */
@Post(':id/steps') @Post(':id/steps')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body; const {type, name, position, config, templateId, autoConnect} = req.body;
@@ -187,10 +186,10 @@ export class Workflows {
* Update a workflow step * Update a workflow step
*/ */
@Patch(':id/steps/:stepId') @Patch(':id/steps/:stepId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const stepId = req.params.stepId; const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body; const {name, position, config, templateId} = req.body;
@@ -214,10 +213,10 @@ export class Workflows {
* Delete a workflow step * Delete a workflow step
*/ */
@Delete(':id/steps/:stepId') @Delete(':id/steps/:stepId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const stepId = req.params.stepId; const stepId = req.params.stepId;
@@ -235,10 +234,10 @@ export class Workflows {
* Create a transition between steps * Create a transition between steps
*/ */
@Post(':id/transitions') @Post(':id/transitions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body; const {fromStepId, toStepId, condition, priority} = req.body;
@@ -265,10 +264,10 @@ export class Workflows {
* Delete a transition * Delete a transition
*/ */
@Delete(':id/transitions/:transitionId') @Delete(':id/transitions/:transitionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const transitionId = req.params.transitionId; const transitionId = req.params.transitionId;
@@ -286,10 +285,10 @@ export class Workflows {
* Start a workflow execution for a contact * Start a workflow execution for a contact
*/ */
@Post(':id/executions') @Post(':id/executions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const {contactId, context} = req.body; const {contactId, context} = req.body;
@@ -311,10 +310,10 @@ export class Workflows {
* List executions for a workflow * List executions for a workflow
*/ */
@Get(':id/executions') @Get(':id/executions')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100); 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 a specific execution with details
*/ */
@Get(':id/executions/:executionId') @Get(':id/executions/:executionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const executionId = req.params.executionId; const executionId = req.params.executionId;
@@ -355,10 +354,10 @@ export class Workflows {
* Cancel a workflow execution * Cancel a workflow execution
*/ */
@Delete(':id/executions/:executionId') @Delete(':id/executions/:executionId')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) { 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 workflowId = req.params.id;
const executionId = req.params.executionId; const executionId = req.params.executionId;
@@ -376,10 +375,10 @@ export class Workflows {
* Cancel all active executions for a workflow * Cancel all active executions for a workflow
*/ */
@Post(':id/executions/cancel-all') @Post(':id/executions/cancel-all')
@Middleware([requireAuth]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) { 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; const workflowId = req.params.id;
if (!workflowId) { if (!workflowId) {
+2 -3
View File
@@ -11,6 +11,7 @@ export enum ErrorCode {
FORBIDDEN = 'FORBIDDEN', FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED', PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED', PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409) // Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', 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 * @param id Optional resource identifier to include in the message
*/ */
public constructor(resource: string, id?: string) { public constructor(resource: string, id?: string) {
const message = id const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
? `${resource} with ID "${id}" was not found`
: `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes // Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = { const errorCodeMap: Record<string, ErrorCode> = {
@@ -1,6 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'; import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers'; import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService // Mock MeterService
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
status: EmailStatus.PENDING, 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 // Simulate processing
await prisma.email.update({ await prisma.email.update({
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
from: '[email protected]', from: '[email protected]',
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [ attachments: toPrismaJson([
{ {
filename: 'document.pdf', filename: 'document.pdf',
content: 'base64encodedcontent', content: 'base64encodedcontent',
contentType: 'application/pdf', contentType: 'application/pdf',
}, },
] as unknown as Prisma.InputJsonValue, ]),
}, },
}); });
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
from: '[email protected]', from: '[email protected]',
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [ attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
}, },
include: { include: {
project: true, 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 {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers'; import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => { describe('Scheduled Campaign Processor', () => {
let projectId: string; let projectId: string;
@@ -1,3 +1,4 @@
import type {ApiRequestCleanupJobData} from '@plunk/types';
import type {Job} from 'bullmq'; import type {Job} from 'bullmq';
import {Worker} from 'bullmq'; import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis'; import type {RedisOptions} from 'ioredis';
@@ -5,7 +6,6 @@ import signale from 'signale';
import {REDIS_URL} from '../app/constants.js'; import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
/** /**
* API Request Cleanup Worker * 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) * Processes campaign batches (queues emails for each contact in the batch)
*/ */
import type {CampaignBatchJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js'; import {campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() { export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>( const worker = new Worker<CampaignBatchJobData>(
@@ -14,11 +16,11 @@ export function createCampaignWorker() {
async (job: Job<CampaignBatchJobData>) => { async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data; 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); 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, connection: campaignQueue.opts.connection,
@@ -27,15 +29,15 @@ export function createCampaignWorker() {
); );
worker.on('completed', job => { 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) => { 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 => { worker.on('error', err => {
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err); signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
}); });
return worker; return worker;
@@ -3,10 +3,11 @@
* Processes domain verification jobs from the BullMQ queue * Processes domain verification jobs from the BullMQ queue
*/ */
import type {DomainVerificationJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js'; import {domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js'; import {checkDomainVerifications} from './domain-verification.js';
+76 -3
View File
@@ -6,10 +6,14 @@
* Scheduled to run every 5 minutes via repeatable jobs * Scheduled to run every 5 minutes via repeatable jobs
*/ */
import React from 'react';
import signale from 'signale'; 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 {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {MembershipService} from '../services/MembershipService.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js'; import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
import {Keys} from '../services/keys.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) // Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) { for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({ 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, skip: i,
take: 99, take: 99,
}); });
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`); signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
} catch (e: unknown) { } catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string}; 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( signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`, `[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
); );
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
delay *= 2; // Exponential backoff delay *= 2; // Exponential backoff
attempt++; attempt++;
} else { } 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; throw e;
} }
} }
@@ -102,6 +120,31 @@ export async function checkDomainVerifications() {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`); 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 // Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id)); await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId)); await redis.del(Keys.Domain.project(dbDomain.projectId));
@@ -111,6 +154,36 @@ export async function checkDomainVerifications() {
if (dbDomain.verified && !isVerified) { if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`); 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.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId)); 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) * 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 {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 {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js'; import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js'; import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js'; import {emailQueue} from '../services/QueueService.js';
import {sendRawEmail} from '../services/SESService.js'; import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
import {DASHBOARD_URI} from '../app/constants.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>( const worker = new Worker<SendEmailJobData>(
emailQueue.name, emailQueue.name,
async (job: Job<SendEmailJobData>) => { async (job: Job<SendEmailJobData>) => {
@@ -38,7 +71,7 @@ export function createEmailWorker() {
// Check if project is disabled // Check if project is disabled
if (email.project.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({ await prisma.email.update({
where: {id: emailId}, where: {id: emailId},
data: { data: {
@@ -138,8 +171,60 @@ export function createEmailWorker() {
sourceType: email.sourceType, sourceType: email.sourceType,
sentAt: new Date().toISOString(), 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) { } 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 // Mark as failed
await prisma.email.update({ await prisma.email.update({
@@ -157,22 +242,22 @@ export function createEmailWorker() {
connection: emailQueue.opts.connection, connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently concurrency: 10, // Process up to 10 emails concurrently
limiter: { limiter: {
max: 25, // Max 25 emails per second max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000, duration: 1000,
}, },
}, },
); );
worker.on('completed', job => { 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) => { 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 => { worker.on('error', err => {
console.error('[EMAIL-PROCESSOR] Worker error:', err); signale.error('[EMAIL-PROCESSOR] Worker error:', err);
}); });
return worker; return worker;
+19 -15
View File
@@ -3,13 +3,15 @@
* Processes CSV contact imports with validation and batch processing * Processes CSV contact imports with validation and batch processing
*/ */
import type {ContactImportJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync'; import {parse} from 'csv-parse/sync';
import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {NtfyService} from '../services/NtfyService.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 const BATCH_SIZE = 100; // Process contacts in batches of 100
@@ -28,7 +30,7 @@ export function createImportWorker() {
async (job: Job<ContactImportJobData>) => { async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data; 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 // Fetch project information for notifications
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
@@ -51,9 +53,9 @@ export function createImportWorker() {
// Decode base64 CSV data // Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8'); const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV // Parse CSV with column header normalization
const records = parse(csvContent, { 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, skip_empty_lines: true,
trim: true, trim: true,
relax_column_count: true, // Allow rows with different column counts relax_column_count: true, // Allow rows with different column counts
@@ -66,15 +68,15 @@ export function createImportWorker() {
throw new Error('CSV file is empty'); 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 // Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows); await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
// Validate that 'email' column exists // Validate that 'email' column exists (case-insensitive)
const firstRecord = records[0]; const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) { 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 // Process contacts in batches
@@ -109,7 +111,7 @@ export function createImportWorker() {
continue; continue;
} }
// Extract subscribed field if present // Extract subscribed field if present (case-insensitive)
const subscribedValue = record.subscribed; const subscribedValue = record.subscribed;
let subscribed: boolean | undefined; let subscribed: boolean | undefined;
@@ -127,8 +129,10 @@ export function createImportWorker() {
const existingContact = await ContactService.findByEmail(projectId, email); const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact; const isUpdate = !!existingContact;
// Upsert contact with subscribed value from CSV if provided, otherwise default to true // Upsert contact with subscribed value from CSV if provided
await ContactService.upsert(projectId, email, data, subscribed ?? true); // 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++; result.successCount++;
if (isUpdate) { if (isUpdate) {
@@ -151,7 +155,7 @@ export function createImportWorker() {
await job.updateProgress(progress); await job.updateProgress(progress);
} }
console.log( signale.info(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`, `[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
); );
@@ -168,7 +172,7 @@ export function createImportWorker() {
return result; return result;
} catch (error) { } 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 // Notify that import has failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@@ -191,15 +195,15 @@ export function createImportWorker() {
); );
worker.on('completed', job => { 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) => { 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 => { worker.on('error', err => {
console.error('[IMPORT-PROCESSOR] Worker error:', err); signale.error('[IMPORT-PROCESSOR] Worker error:', err);
}); });
return worker; return worker;
+11 -9
View File
@@ -4,11 +4,13 @@
*/ */
import {CampaignStatus} from '@plunk/db'; import {CampaignStatus} from '@plunk/db';
import type {ScheduledCampaignJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js'; import {CampaignService} from '../services/CampaignService.js';
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js'; import {scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() { export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>( const worker = new Worker<ScheduledCampaignJobData>(
@@ -16,7 +18,7 @@ export function createScheduledCampaignWorker() {
async (job: Job<ScheduledCampaignJobData>) => { async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data; const {campaignId} = job.data;
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`); signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project // Get campaign with project
const campaign = await prisma.campaign.findUnique({ const campaign = await prisma.campaign.findUnique({
@@ -29,13 +31,13 @@ export function createScheduledCampaignWorker() {
}); });
if (!campaign) { if (!campaign) {
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`); signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return; return;
} }
// Check if project is disabled // Check if project is disabled
if (campaign.project.disabled) { if (campaign.project.disabled) {
console.warn( signale.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`, `[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
); );
await prisma.campaign.update({ await prisma.campaign.update({
@@ -47,7 +49,7 @@ export function createScheduledCampaignWorker() {
// Verify campaign is still in SCHEDULED status // Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) { if (campaign.status !== CampaignStatus.SCHEDULED) {
console.warn( signale.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`, `[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
); );
return; return;
@@ -56,7 +58,7 @@ export function createScheduledCampaignWorker() {
// Start sending the campaign // Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId); 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, connection: scheduledQueue.opts.connection,
@@ -65,15 +67,15 @@ export function createScheduledCampaignWorker() {
); );
worker.on('completed', job => { 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) => { 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 => { worker.on('error', err => {
console.error('[SCHEDULED-PROCESSOR] Worker error:', err); signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
}); });
return worker; return worker;
+2 -1
View File
@@ -3,12 +3,13 @@
* Processes segment count update jobs from the BullMQ queue * Processes segment count update jobs from the BullMQ queue
*/ */
import type {SegmentCountJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {NtfyService} from '../services/NtfyService.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'; import {SegmentService} from '../services/SegmentService.js';
/** /**
+7 -1
View File
@@ -10,6 +10,7 @@ import {Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js'; import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createBulkContactWorker} from './bulk-contact-processor.js';
import {createCampaignWorker} from './campaign-processor.js'; import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js'; import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js'; import {createEmailWorker} from './email-processor.js';
@@ -25,7 +26,7 @@ async function startWorkers() {
try { try {
// Start email worker // Start email worker
const emailWorker = createEmailWorker(); const emailWorker = await createEmailWorker();
workers.push({name: 'email', worker: emailWorker}); workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started'); signale.success('[WORKER] Email worker started');
@@ -49,6 +50,11 @@ async function startWorkers() {
workers.push({name: 'import', worker: importWorker}); workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started'); 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 // Start segment count worker
const segmentCountWorker = createSegmentCountWorker(); const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker}); workers.push({name: 'segment-count', worker: segmentCountWorker});
@@ -3,9 +3,11 @@
* Processes workflow steps from the queue (for delayed steps) * Processes workflow steps from the queue (for delayed steps)
*/ */
import type {WorkflowStepJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; 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'; import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() { export function createWorkflowWorker() {
@@ -33,15 +35,15 @@ export function createWorkflowWorker() {
); );
worker.on('completed', job => { 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) => { 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 => { worker.on('error', err => {
console.error('[WORKFLOW-PROCESSOR] Worker error:', err); signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
}); });
return worker; return worker;
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
projectId = project.id; projectId = project.id;
userId = user.id; userId = user.id;
// Mock request object
req = { req = {
method: 'POST', method: 'POST',
path: '/v1/send', path: '/v1/send',
@@ -334,11 +333,6 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true}); await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100)); 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 // Restore original value
if (originalEnv !== undefined) { if (originalEnv !== undefined) {
+98 -126
View File
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken'; import jsonwebtoken from 'jsonwebtoken';
import {JWT_SECRET} from '../app/constants.js'; import type {AuthResponse} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
export interface AuthResponse { import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
type: 'jwt' | 'apiKey'; import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
userId?: string; import {MembershipService} from '../services/MembershipService.js';
projectId: string; import {ProjectService} from '../services/ProjectService.js';
} import {UserService} from '../services/UserService.js';
/** /**
* Middleware to check if this unsubscribe is authenticated on the dashboard * Middleware to check if this unsubscribe is authenticated on the dashboard
@@ -65,7 +63,7 @@ export const jwt = {
* @param request The express request object * @param request The express request object
*/ */
export function parseJwt(request: Request): string { export function parseJwt(request: Request): string {
const token: string | undefined = request.cookies.token; const token: string | undefined = request.cookies.next_token;
if (!token) { if (!token) {
throw new NotAuthenticated(); throw new NotAuthenticated();
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
return id; 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) * 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 * 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]; 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 // Look up project by public key only
const project = await prisma.project.findFirst({ const project = await ProjectService.public(apiKey);
where: {
public: apiKey,
},
});
if (!project) { if (!project) {
throw new HttpException( 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 // Check if project is disabled - block write operations
if (project.disabled) { if (project.disabled) {
const method = req.method.toUpperCase(); 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(); next();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
const apiKey = parts[1]; 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 // Look up project by secret key only
const project = await prisma.project.findFirst({ const project = await ProjectService.secret(apiKey);
where: {
secret: apiKey,
},
});
if (!project) { if (!project) {
throw new HttpException( 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 // Check if project is disabled - block write operations
if (project.disabled) { if (project.disabled) {
const method = req.method.toUpperCase(); 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(); next();
} catch (error) { } catch (error) {
next(error); next(error);
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
} }
const apiKey = parts[1]; 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) // Look up project by secret key only (public keys not allowed)
const project = await prisma.project.findFirst({ const project = await ProjectService.secret(apiKey);
where: {
secret: apiKey,
},
});
if (!project) { if (!project) {
throw new HttpException( 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 // Check if project is disabled - block write operations
if (project.disabled) { if (project.disabled) {
const method = req.method.toUpperCase(); 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(); 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 // Verify user has access to this project and get project status
const [membership, project] = await Promise.all([ const [membership, project] = await Promise.all([
prisma.membership.findUnique({ MembershipService.getMembership(userId, projectId),
where: { ProjectService.id(projectId),
userId_projectId: {
userId,
projectId,
},
},
}),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
]); ]);
if (!membership) { if (!membership) {
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED); 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 // Check if project is disabled - block write operations
if (project?.disabled) { if (project?.disabled) {
const method = req.method.toUpperCase(); 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 next();
res.locals.auth = { } catch (error) {
type: 'jwt', next(error);
userId, }
projectId, };
} as AuthResponse;
/**
* 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(); next();
} catch (error) { } catch (error) {
+16 -58
View File
@@ -1,58 +1,11 @@
import type {Prisma} from '@plunk/db'; 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 {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {Keys} from './keys.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;
}
/** /**
* Activity Service * Activity Service
@@ -105,7 +58,7 @@ export class ActivityService {
contactId?: string, contactId?: string,
startDate?: Date, startDate?: Date,
endDate?: Date, endDate?: Date,
): Promise<PaginatedActivities> { ): Promise<CursorPaginatedResponse<Activity>> {
// Cap limit to prevent abuse // Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT); 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; const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return { return {
activities: results, data: results,
nextCursor, cursor: nextCursor,
hasMore, hasMore,
}; };
} }
@@ -170,7 +123,7 @@ export class ActivityService {
*/ */
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> { public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache // 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 { try {
const cached = await redis.get(cacheKey); const cached = await redis.get(cacheKey);
@@ -178,7 +131,7 @@ export class ActivityService {
return JSON.parse(cached); return JSON.parse(cached);
} }
} catch (error) { } 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 // Default date range to last 30 days if not specified
@@ -241,7 +194,7 @@ export class ActivityService {
try { try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats)); await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) { } catch (error) {
console.warn('[ACTIVITY] Failed to cache stats:', error); signale.warn('[ACTIVITY] Failed to cache stats:', error);
} }
return stats; return stats;
@@ -261,7 +214,7 @@ export class ActivityService {
await redis.del(...keys); await redis.del(...keys);
} }
} catch (error) { } 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) { for (const email of emails) {
const baseMetadata = { const baseMetadata = {
subject: email.subject, subject: email.subject,
body: email.body,
from: email.from,
fromName: email.fromName,
replyTo: email.replyTo,
toName: email.toName,
sourceType: email.sourceType, sourceType: email.sourceType,
campaignName: email.campaign?.name, campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name, workflowName: email.workflowExecution?.workflow?.name,
+17 -5
View File
@@ -1,5 +1,6 @@
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/** /**
* Time series data point for analytics * Time series data point for analytics
@@ -57,14 +58,16 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate; const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(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< const result = await prisma.$queryRaw<
{ {
date: Date; date: Date;
@@ -190,7 +193,11 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
@@ -294,7 +301,12 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // 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); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached); return JSON.parse(cached);
+206 -38
View File
@@ -1,44 +1,17 @@
import {EmailSourceType} from '@plunk/db'; 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 signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.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 * Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category * Handles usage tracking and enforcement of billing limits per email category
@@ -221,7 +194,8 @@ export class BillingLimitService {
project.billingLimitTransactional !== null; project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit // Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
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 totalUsage = await this.getTotalUsage(projectId);
const freeLimit = this.FREE_TIER_TOTAL_LIMIT; const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / freeLimit) * 100; const percentage = (totalUsage / freeLimit) * 100;
@@ -236,6 +210,9 @@ export class BillingLimitService {
EmailSourceType.TRANSACTIONAL, // Use generic type for notification EmailSourceType.TRANSACTIONAL, // Use generic type for notification
); );
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
return { return {
allowed: false, allowed: false,
warning: false, warning: false,
@@ -257,6 +234,16 @@ export class BillingLimitService {
percentage, percentage,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification 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 { return {
@@ -297,6 +284,9 @@ export class BillingLimitService {
if (project) { if (project) {
// Send notification about limit exceeded // Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType); await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
} }
return { return {
@@ -320,7 +310,17 @@ export class BillingLimitService {
}); });
if (project) { 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; project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): show total usage with shared limit // Free tier projects (no subscription and no custom limits): show total usage with shared limit
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 totalUsage = workflowUsage + campaignUsage + transactionalUsage;
const limit = this.FREE_TIER_TOTAL_LIMIT; const limit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / limit) * 100; const percentage = (totalUsage / limit) * 100;
@@ -455,7 +456,11 @@ export class BillingLimitService {
/** /**
* Invalidate usage cache for a project * 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 * @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 * Get Redis cache key for usage count
*/ */
@@ -481,7 +549,7 @@ export class BillingLimitService {
const now = new Date(); const now = new Date();
const year = now.getFullYear(); const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
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); const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return {start, end}; 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 type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {FilterCondition} from '@plunk/types'; 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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js'; import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js'; import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js'; import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.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'; import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance) 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 { export class CampaignService {
/** /**
* Create a new campaign * Create a new campaign
@@ -71,7 +48,7 @@ export class CampaignService {
SegmentService.validateCondition(data.audienceCondition); SegmentService.validateCondition(data.audienceCondition);
} }
// Create campaign // Create campaign with initial recipient count of 0
const campaign = await prisma.campaign.create({ const campaign = await prisma.campaign.create({
data: { data: {
projectId, projectId,
@@ -83,9 +60,10 @@ export class CampaignService {
fromName: data.fromName, fromName: data.fromName,
replyTo: data.replyTo, replyTo: data.replyTo,
audienceType: data.audienceType, audienceType: data.audienceType,
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue, audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId, segmentId: data.segmentId,
status: CampaignStatus.DRAFT, status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
}, },
include: { include: {
project: { 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 // Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId); await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return campaign; return updatedCampaign;
} }
/** /**
@@ -123,7 +108,7 @@ export class CampaignService {
if (data.audienceCondition) { if (data.audienceCondition) {
SegmentService.validateCondition(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) { if (data.segmentId !== undefined) {
@@ -147,10 +132,25 @@ export class CampaignService {
delete (updateData as Record<string, unknown>).segmentId; delete (updateData as Record<string, unknown>).segmentId;
} }
return prisma.campaign.update({ // Update the campaign first
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId}, where: {id: campaignId},
data: updateData, 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; page?: number;
pageSize?: 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 {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
@@ -207,7 +207,7 @@ export class CampaignService {
]); ]);
return { return {
campaigns, data: campaigns,
total, total,
page, page,
pageSize, pageSize,
@@ -252,7 +252,7 @@ export class CampaignService {
const campaign = await this.get(projectId, campaignId); const campaign = await this.get(projectId, campaignId);
// Create a new campaign with the same data but reset status and stats // Create a new campaign with the same data but reset status and stats
return prisma.campaign.create({ const duplicatedCampaign = await prisma.campaign.create({
data: { data: {
projectId, projectId,
name: `${campaign.name} (Copy)`, name: `${campaign.name} (Copy)`,
@@ -266,7 +266,7 @@ export class CampaignService {
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue, audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId, segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT, status: CampaignStatus.DRAFT,
totalRecipients: 0, totalRecipients: 0, // Will be updated below
sentCount: 0, sentCount: 0,
deliveredCount: 0, deliveredCount: 0,
openedCount: 0, openedCount: 0,
@@ -274,6 +274,13 @@ export class CampaignService {
bouncedCount: 0, 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'); 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) { if (scheduledFor) {
// Schedule for later // Schedule for later
if (scheduledFor.getTime() <= Date.now()) { if (scheduledFor.getTime() <= Date.now()) {
@@ -412,7 +437,7 @@ export class CampaignService {
} }
if (campaign.status !== CampaignStatus.SENDING) { 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; return;
} }
@@ -426,6 +451,7 @@ export class CampaignService {
const contactData = const contactData =
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {}; contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
const variables = { const variables = {
id: contact.id,
email: contact.email, email: contact.email,
...contactData, ...contactData,
data: contactData, data: contactData,
@@ -458,21 +484,11 @@ export class CampaignService {
replyTo: campaign.replyTo || undefined, replyTo: campaign.replyTo || undefined,
}); });
} catch (error) { } 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 // 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 // Queue next batch if there are more contacts
if (hasMore && nextCursor) { if (hasMore && nextCursor) {
await QueueService.queueCampaignBatch({ await QueueService.queueCampaignBatch({
@@ -482,27 +498,6 @@ export class CampaignService {
limit, limit,
cursor: nextCursor, 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({ await prisma.campaign.update({
where: {id: campaignId}, where: {id: campaignId},
data: { data: {
sentCount: sentEmails,
deliveredCount: deliveredEmails, deliveredCount: deliveredEmails,
openedCount: openedEmails, openedCount: openedEmails,
clickedCount: clickedEmails, clickedCount: clickedEmails,
@@ -728,7 +724,7 @@ export class CampaignService {
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: { case CampaignAudienceType.FILTERED: {
const condition = campaign.audienceCondition as unknown as FilterCondition; const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
if (!condition) { if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); 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'); 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); const segmentWhere = SegmentService.buildConditionClause(condition);
return { return {
+193 -16
View File
@@ -1,17 +1,12 @@
import {type Contact, Prisma} from '@plunk/db'; 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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js'; import {EventService} from './EventService.js';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export class ContactService { export class ContactService {
/** /**
* Get all contacts for a project with cursor-based pagination * Get all contacts for a project with cursor-based pagination
@@ -22,7 +17,7 @@ export class ContactService {
limit = 20, limit = 20,
cursor?: string, cursor?: string,
search?: string, search?: string,
): Promise<PaginatedContacts> { ): Promise<CursorPaginatedResponse<Contact>> {
const where: Prisma.ContactWhereInput = { const where: Prisma.ContactWhereInput = {
projectId, projectId,
...(search ...(search
@@ -57,7 +52,7 @@ export class ContactService {
const total = !cursor ? await prisma.contact.count({where}) : 0; const total = !cursor ? await prisma.contact.count({where}) : 0;
return { return {
contacts: results, data: results,
total, total,
cursor: nextCursor, cursor: nextCursor,
hasMore, hasMore,
@@ -224,11 +219,32 @@ export class ContactService {
// Merge new data (if provided) // Merge new data (if provided)
if (data) { if (data) {
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
// Skip reserved fields // Skip reserved system-generated fields
if (key === 'plunk_id' || key === 'plunk_email') { // 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; 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 } // Handle non-persistent data format: { value: "...", persistent: false }
if ( if (
typeof value === 'object' && typeof value === 'object' &&
@@ -255,7 +271,7 @@ export class ContactService {
const updated = await prisma.contact.update({ const updated = await prisma.contact.update({
where: {id: existing.id}, where: {id: existing.id},
data: { 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} : {}), ...(subscribed !== undefined ? {subscribed} : {}),
}, },
}); });
@@ -275,7 +291,7 @@ export class ContactService {
data: { data: {
projectId, projectId,
email, 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, subscribed: subscribed ?? true,
}, },
}); });
@@ -297,11 +313,18 @@ export class ContactService {
Object.assign(mergedData, contact.data); 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 // Add temporary (non-persistent) data
if (temporaryData) { if (temporaryData) {
for (const [key, value] of Object.entries(temporaryData)) { for (const [key, value] of Object.entries(temporaryData)) {
// Skip reserved fields // Skip reserved system-generated fields
if (key === 'plunk_id' || key === 'plunk_email') { const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue; continue;
} }
@@ -339,6 +362,25 @@ export class ContactService {
return contact; 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 * PUBLIC: Subscribe a contact
*/ */
@@ -635,6 +677,117 @@ export class ContactService {
return {deletedFrom: result}; 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) * Helper: Check if a field is used in a filter condition (recursive)
*/ */
@@ -679,4 +832,28 @@ export class ContactService {
return false; 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 {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 {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js'; import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js'; import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
@@ -81,6 +86,31 @@ export class DomainService {
// Send notification about domain verified // Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id); await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
// Send email notification about domain verified
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) { } else if (attributes.status !== 'Success' && domain.verified) {
const updatedDomain = await prisma.domain.update({ const updatedDomain = await prisma.domain.update({
where: {id: domainId}, where: {id: domainId},
@@ -93,7 +123,41 @@ export class DomainService {
}); });
// Send notification about domain verification failed // 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 { return {
+72 -15
View File
@@ -1,11 +1,12 @@
import type {Contact, Email, Prisma, Project} from '@plunk/db'; import type {Contact, Email, Prisma, Project} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {renderTemplate} from '@plunk/shared'; import {createTranslatorSync, renderTemplate} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js'; import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js'; import {DomainService} from './DomainService.js';
@@ -91,8 +92,8 @@ export class EmailService {
fromName: params.fromName, fromName: params.fromName,
toName: params.toName, toName: params.toName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType: EmailSourceType.TRANSACTIONAL, sourceType: EmailSourceType.TRANSACTIONAL,
templateId: params.templateId, templateId: params.templateId,
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
@@ -152,8 +153,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
campaignId: params.campaignId, campaignId: params.campaignId,
@@ -213,8 +214,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId, workflowExecutionId: params.workflowExecutionId,
@@ -250,8 +251,8 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined, headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId, workflowExecutionId: params.workflowExecutionId,
@@ -332,6 +333,7 @@ export class EmailService {
subject: email.subject, subject: email.subject,
body: email.body, body: email.body,
data: { data: {
id: email.contact.id,
email: email.contact.email, email: email.contact.email,
...contactData, ...contactData,
data: contactData, data: contactData,
@@ -407,7 +409,7 @@ export class EmailService {
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {
console.error(`[EMAIL] Failed to send email ${emailId}:`, error); signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
// Mark as failed // Mark as failed
await prisma.email.update({ await prisma.email.update({
@@ -502,6 +504,42 @@ export class EmailService {
data: updateData, 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 // Track event
await prisma.event.create({ await prisma.event.create({
data: { data: {
@@ -509,7 +547,7 @@ export class EmailService {
contactId: email.contactId, contactId: email.contactId,
emailId: email.id, emailId: email.id,
name: `email.${eventType}`, 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; let html = content;
const unsubscribeHtml = includeUnsubscribe 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> <tbody>
<tr> <tr>
<td> <td>
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;"> <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);"> <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 ${unsubscribeText}
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">update your preferences</a>. <a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
</p> </p>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table>` </table>`;
})()
: ''; : '';
// Add Plunk badge if billing is enabled and project has no subscription (free tier) // 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 type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db'; import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types'; import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js'; import {WorkflowExecutionService} from './WorkflowExecutionService.js';
@@ -30,7 +33,7 @@ export class EventService {
contactId, contactId,
emailId, emailId,
name: eventName, 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 * Should be called when workflows are enabled/disabled or updated
*/ */
public static async invalidateWorkflowCache(projectId: string): Promise<void> { public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
try { try {
await redis.del(cacheKey); await redis.del(cacheKey);
} catch (error) { } 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 * @param eventName - The event name to delete
*/ */
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> { public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of system events // Prevent deletion of reserved system events
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) { if (this.isReservedEvent(eventName)) {
throw new Error('Cannot delete system events (email.* or segment.*)'); throw new Error(`Cannot delete reserved system event: ${eventName}`);
} }
// Check if event is in use // Check if event is in use
@@ -311,6 +314,36 @@ export class EventService {
return {deletedCount: result.count}; 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 * Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance * Uses Redis caching for enabled workflows to improve performance
@@ -322,7 +355,7 @@ export class EventService {
data?: Record<string, unknown>, data?: Record<string, unknown>,
): Promise<void> { ): Promise<void> {
// Try to get workflows from cache // Try to get workflows from cache
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
let workflows; let workflows;
try { try {
@@ -331,7 +364,7 @@ export class EventService {
workflows = JSON.parse(cached); workflows = JSON.parse(cached);
} }
} catch (error) { } 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 // If not in cache, fetch from database
@@ -353,7 +386,7 @@ export class EventService {
try { try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows)); await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) { } 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 { } else {
// If event is not contact-specific, you might want different logic // If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip // 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) { 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; return;
} }
@@ -429,7 +462,7 @@ export class EventService {
const triggerStep = workflow.steps[0]; const triggerStep = workflow.steps[0];
if (!triggerStep) { if (!triggerStep) {
console.error(`[EVENT] Workflow ${workflowId} trigger step not found`); signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return; return;
} }
@@ -440,18 +473,18 @@ export class EventService {
contactId, contactId,
status: 'RUNNING', status: 'RUNNING',
currentStepId: triggerStep.id, 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)' : ''}`, `[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
); );
// Start executing the workflow // Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) { } 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'; 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 * Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable * Supports configurable ntfy server URL via NTFY_URL environment variable
@@ -241,12 +207,25 @@ export class NtfyService {
* Notify about security warning (non-critical) * Notify about security warning (non-critical)
*/ */
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> { 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(', '); const warningText = warnings.join(', ');
await this.sendDefault( await this.sendDefault(
'Security Warning', 'Security Warning',
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`, `Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD], [NtfyTag.WARNING, NtfyTag.SHIELD],
); );
// Throttle for 1 hour
await redis.setex(cacheKey, 3600, '1');
} }
/** /**
@@ -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 * Notify about new user account created via OAuth - LOW priority
*/ */
@@ -610,11 +602,24 @@ export class NtfyService {
percentage: number, percentage: number,
sourceType: string, sourceType: string,
): Promise<void> { ): 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( await this.sendDefault(
'Billing Limit Warning', 'Billing Limit Warning',
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`, `Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART], [NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
); );
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
} }
// ===== Billing and usage limit notifications ===== // ===== Billing and usage limit notifications =====
@@ -629,11 +634,24 @@ export class NtfyService {
limit: number, limit: number,
sourceType: string, sourceType: string,
): Promise<void> { ): 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( await this.sendUrgent(
'Billing Limit Exceeded', 'Billing Limit Exceeded',
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`, `Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL], [NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
); );
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
} }
/** /**
+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 Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis'; import type {RedisOptions} from 'ioredis';
import signale from 'signale'; 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 {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.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 * 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 * 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 * Queue segment count update job
*/ */
@@ -354,6 +373,7 @@ export class QueueService {
segmentCountCounts, segmentCountCounts,
domainVerificationCounts, domainVerificationCounts,
apiRequestCleanupCounts, apiRequestCleanupCounts,
bulkContactCounts,
] = await Promise.all([ ] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'), emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.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'), segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'), domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'), apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]); ]);
return { return {
@@ -374,6 +395,7 @@ export class QueueService {
segmentCount: segmentCountCounts, segmentCount: segmentCountCounts,
domainVerification: domainVerificationCounts, domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts, apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
}; };
} }
@@ -390,6 +412,7 @@ export class QueueService {
segmentCountQueue.pause(), segmentCountQueue.pause(),
domainVerificationQueue.pause(), domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(), apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
]); ]);
} }
@@ -406,6 +429,7 @@ export class QueueService {
segmentCountQueue.resume(), segmentCountQueue.resume(),
domainVerificationQueue.resume(), domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(), apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
]); ]);
} }
@@ -430,6 +454,8 @@ export class QueueService {
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'), segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
domainVerificationQueue.clean(gracePeriod, 10, 'completed'), domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'), 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(), segmentCountQueue.close(),
domainVerificationQueue.close(), domainVerificationQueue.close(),
apiRequestCleanupQueue.close(), apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
]); ]);
} }
} }
+12 -11
View File
@@ -1,19 +1,21 @@
import { import {
S3Client,
PutObjectCommand,
CreateBucketCommand, CreateBucketCommand,
HeadBucketCommand, HeadBucketCommand,
PutBucketPolicyCommand, PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3'; } from '@aws-sdk/client-s3';
import crypto from 'crypto'; import crypto from 'crypto';
import signale from 'signale';
import { import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID, S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET, S3_ACCESS_KEY_SECRET,
S3_BUCKET, S3_BUCKET,
S3_PUBLIC_URL,
S3_FORCE_PATH_STYLE,
S3_ENABLED, S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PUBLIC_URL,
} from '../app/constants.js'; } from '../app/constants.js';
/** /**
@@ -44,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
let bucketExists = true; let bucketExists = true;
try { try {
// Check if bucket exists
await s3Client.send( await s3Client.send(
new HeadBucketCommand({ new HeadBucketCommand({
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
@@ -69,13 +70,13 @@ export async function initializeBucket(): Promise<void> {
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
}), }),
); );
console.log(`[S3] Created bucket: ${S3_BUCKET}`); signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
} catch (createError) { } catch (createError) {
console.error('[S3] Failed to create bucket:', createError); signale.error('[S3] Failed to create bucket:', createError);
throw createError; throw createError;
} }
} else { } else {
console.error('[S3] Failed to check bucket:', error); signale.error('[S3] Failed to check bucket:', error);
throw error; throw error;
} }
} }
@@ -103,10 +104,10 @@ export async function initializeBucket(): Promise<void> {
); );
if (!bucketExists) { 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) { } 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 // 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 {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import { import {
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
@@ -6,7 +7,8 @@ import {
AWS_SES_SECRET_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI, DASHBOARD_URI,
SES_CONFIGURATION_SET, SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
} from '../app/constants.js'; } from '../app/constants.js';
/** /**
@@ -160,10 +162,15 @@ ${breakLongLines(attachment.content, 76, true)}`,
: '' : ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`; }${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 // Send via SES
const response = await ses.sendRawEmail({ const response = await ses.sendRawEmail({
Destinations: destinations, Destinations: destinations,
ConfigurationSetName: tracking ? SES_CONFIGURATION_SET : SES_CONFIGURATION_SET_NO_TRACKING, ConfigurationSetName: configurationSetName,
RawMessage: { RawMessage: {
Data: new TextEncoder().encode(rawMessage), Data: new TextEncoder().encode(rawMessage),
}, },
@@ -250,3 +257,26 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
ForwardingEnabled: false, 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.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 * Security thresholds for bounce and complaint rates
@@ -14,10 +19,10 @@ const SECURITY_THRESHOLDS = {
MIN_EMAILS_FOR_ENFORCEMENT: 100, MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only) // Bounce rate thresholds (hard bounces only)
BOUNCE_7DAY_WARNING: 3, BOUNCE_7DAY_WARNING: 5,
BOUNCE_7DAY_CRITICAL: 8, BOUNCE_7DAY_CRITICAL: 10,
BOUNCE_ALLTIME_WARNING: 2, BOUNCE_ALLTIME_WARNING: 4,
BOUNCE_ALLTIME_CRITICAL: 5, BOUNCE_ALLTIME_CRITICAL: 8,
// Complaint rate thresholds (spam reports) // Complaint rate thresholds (spam reports)
COMPLAINT_7DAY_WARNING: 0.05, COMPLAINT_7DAY_WARNING: 0.05,
@@ -45,7 +50,6 @@ interface SecurityStatus {
} }
export class SecurityService { export class SecurityService {
private static readonly CACHE_PREFIX = 'security';
private static readonly CACHE_TTL = 300; // 5 minutes private static readonly CACHE_TTL = 300; // 5 minutes
/** /**
@@ -54,7 +58,7 @@ export class SecurityService {
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> { public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
try { try {
// Try to get from cache first // Try to get from cache first
const cacheKey = this.getCacheKey(projectId, 'rates'); const cacheKey = Keys.Security.rates(projectId);
const cached = await redis.get(cacheKey); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
@@ -107,9 +111,31 @@ export class SecurityService {
// Get current security status // Get current security status
const status = await this.getSecurityStatus(projectId); const status = await this.getSecurityStatus(projectId);
// If project should be disabled, disable it // If project should be disabled, disable it (only if auto-disable is enabled)
if (status.shouldDisable) { if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
await this.disableProject(projectId, status); 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) { } else if (status.warnings.length > 0) {
// Log warnings for monitoring // Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings); 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> { public static async invalidateCache(projectId: string): Promise<void> {
try { try {
const cacheKey = this.getCacheKey(projectId, 'rates'); const cacheKey = Keys.Security.rates(projectId);
await redis.del(cacheKey); await redis.del(cacheKey);
} catch (error) { } catch (error) {
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, 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) * 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 * Calculate bounce and complaint rates for a project
*/ */
@@ -233,10 +275,11 @@ export class SecurityService {
const warnings: string[] = []; const warnings: string[] = [];
// Only enforce if minimum emails threshold is met // 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 (only if 7-day volume is sufficient)
// Check 7-day bounce rate if (hasMinimumVolume7Day) {
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) { if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
violations.push( violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`, `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) 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}%)`, `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 // Check all-time bounce rate
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) { if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
violations.push( 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 // Check all-time complaint rate
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) { if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
violations.push( violations.push(
@@ -345,6 +393,26 @@ export class SecurityService {
// Send urgent notification about project suspension // Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations); 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) { } catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error); signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
} }
+155 -38
View File
@@ -1,5 +1,7 @@
import {type Contact, Prisma, type Segment} from '@plunk/db'; 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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -7,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js'; import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types'; 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 * Convert segment name to a URL-safe slug for event names
* Example: "VIP Customers" -> "vip-customers" * Example: "VIP Customers" -> "vip-customers"
@@ -71,9 +64,9 @@ export class SegmentService {
segmentId: string, segmentId: string,
page = 1, page = 1,
pageSize = 20, pageSize = 20,
): Promise<PaginatedContacts> { ): Promise<PaginatedResponse<Contact>> {
const segment = await this.get(projectId, segmentId); 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 where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
@@ -89,7 +82,7 @@ export class SegmentService {
]); ]);
return { return {
contacts, data: contacts,
total, total,
page, page,
pageSize, pageSize,
@@ -121,7 +114,7 @@ export class SegmentService {
projectId, projectId,
name: data.name, name: data.name,
description: data.description, description: data.description,
condition: data.condition as unknown as Prisma.InputJsonValue, condition: toPrismaJson(data.condition),
trackMembership: data.trackMembership ?? false, trackMembership: data.trackMembership ?? false,
memberCount, memberCount,
}, },
@@ -168,7 +161,7 @@ export class SegmentService {
updateData.description = data.description; updateData.description = data.description;
} }
if (data.condition !== undefined) { if (data.condition !== undefined) {
updateData.condition = data.condition as unknown as Prisma.InputJsonValue; updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes // Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition); const where = this.buildWhereClause(projectId, data.condition);
@@ -236,7 +229,7 @@ export class SegmentService {
*/ */
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> { public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId); const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition; const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
@@ -267,7 +260,7 @@ export class SegmentService {
await Promise.all( await Promise.all(
batch.map(async segment => { batch.map(async segment => {
try { try {
const condition = segment.condition as unknown as FilterCondition; const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition); const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where}); const memberCount = await prisma.contact.count({where});
@@ -276,7 +269,7 @@ export class SegmentService {
data: {memberCount}, data: {memberCount},
}); });
} catch (error) { } 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'); 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); const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues // Get all matching contacts using cursor-based pagination to avoid memory issues
@@ -363,14 +356,42 @@ export class SegmentService {
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) { for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE); const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
// Check which contacts already have a membership record (inactive)
const existingMemberships = await prisma.segmentMembership.findMany({
where: {
segmentId,
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({ await prisma.segmentMembership.createMany({
data: batch.map(contactId => ({ data: newEntries.map(contactId => ({
segmentId, segmentId,
contactId, contactId,
enteredAt: new Date(), enteredAt: new Date(),
})), })),
skipDuplicates: true, 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 // Create segment-specific entry events for each contact in the batch
for (const contactId of batch) { for (const contactId of batch) {
@@ -383,7 +404,7 @@ export class SegmentService {
segmentName: segment.name, segmentName: segment.name,
}); });
} catch (error) { } 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, segmentName: segment.name,
}); });
} catch (error) { } 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}, data: {memberCount: matchingContactIds.size},
}); });
console.log( signale.info(
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`, `[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") // Handle JSON field paths (e.g., "data.plan")
if (field.startsWith('data.')) { if (field.startsWith('data.')) {
const jsonPath = field.substring(5); // Remove "data." prefix const jsonPath = field.substring(5); // Remove "data." prefix
return this.buildJsonFieldCondition(jsonPath, operator, value); return this.buildJsonFieldCondition(jsonPath, operator, value, unit);
} }
// Handle regular fields // 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) * 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) * Build Prisma clause from filter group (recursive)
*/ */
@@ -637,13 +658,38 @@ export class SegmentService {
/** /**
* Build condition for JSON fields (stored in contact.data) * 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('.'); const path = jsonPath.split('.');
switch (operator) { switch (operator) {
case 'equals': 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}}; return {data: {path, equals: value as Prisma.InputJsonValue}};
case 'notEquals': 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}}}; return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
case 'contains': case 'contains':
return {data: {path, string_contains: String(value)}}; return {data: {path, string_contains: String(value)}};
@@ -669,6 +715,20 @@ export class SegmentService {
return { return {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}], 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: default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`); throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
} }
@@ -720,6 +780,28 @@ export class SegmentService {
unit?: 'days' | 'hours' | 'minutes', unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput { ): Prisma.ContactWhereInput {
switch (operator) { 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': case 'greaterThan':
return {[field]: {gt: new Date(value as string | number | Date)}}; return {[field]: {gt: new Date(value as string | number | Date)}};
case 'lessThan': 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 * Build condition for event-based filters
* Uses Prisma relations to efficiently query contacts who triggered specific events * 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 type {Template} from '@plunk/db';
import {Prisma} from '@plunk/db'; import {Prisma} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
export interface PaginatedTemplates {
templates: Template[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export class TemplateService { export class TemplateService {
/** /**
* Get all templates for a project with pagination * Get all templates for a project with pagination
@@ -23,7 +16,7 @@ export class TemplateService {
pageSize = 20, pageSize = 20,
search?: string, search?: string,
type?: Template['type'], type?: Template['type'],
): Promise<PaginatedTemplates> { ): Promise<PaginatedResponse<Template>> {
const skip = (page - 1) * pageSize; const skip = (page - 1) * pageSize;
const where: Prisma.TemplateWhereInput = { const where: Prisma.TemplateWhereInput = {
@@ -51,7 +44,7 @@ export class TemplateService {
]); ]);
return { return {
templates, data: templates,
total, total,
page, page,
pageSize, pageSize,
@@ -88,8 +81,8 @@ export class TemplateService {
subject: string; subject: string;
body: string; body: string;
from: string; from: string;
fromName?: string; fromName?: string | null;
replyTo?: string; replyTo?: string | null;
type?: Template['type']; type?: Template['type'];
}, },
): Promise<Template> { ): Promise<Template> {
@@ -120,8 +113,8 @@ export class TemplateService {
subject?: string; subject?: string;
body?: string; body?: string;
from?: string; from?: string;
fromName?: string; fromName?: string | null;
replyTo?: string; replyTo?: string | null;
type?: Template['type']; type?: Template['type'];
}, },
): Promise<Template> { ): Promise<Template> {
+5 -1
View File
@@ -43,7 +43,7 @@ function getCookieDomain(): string | undefined {
} }
export class UserService { export class UserService {
public static readonly COOKIE_NAME = 'token'; public static readonly COOKIE_NAME = 'next_token';
public static async id(id: string) { public static async id(id: string) {
return wrapRedis(Keys.User.id(id), async () => { return wrapRedis(Keys.User.id(id), async () => {
@@ -52,6 +52,10 @@ export class UserService {
} }
public static async email(email: string) { public static async email(email: string) {
if (!email) {
return null;
}
return wrapRedis(Keys.User.email(email), async () => { return wrapRedis(Keys.User.email(email), async () => {
return prisma.user.findFirst({ return prisma.user.findFirst({
where: { where: {
@@ -1,14 +1,15 @@
import type { import type {
Contact, Contact,
Prisma, Prisma,
Template,
Workflow,
WorkflowExecution, WorkflowExecution,
WorkflowStep, WorkflowStep,
WorkflowStepExecution, WorkflowStepExecution,
Template,
Workflow,
} from '@plunk/db'; } from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js'; import {QueueService} from './QueueService.js';
// Type aliases for workflow execution context
type StepConfig = Prisma.JsonValue; type StepConfig = Prisma.JsonValue;
type StepResult = Record<string, unknown>; type StepResult = Record<string, unknown>;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow}; type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`); signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
// This is a delayed step - continue with execution // This is a delayed step - continue with execution
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) { } 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 return; // Already completed or cancelled
} }
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
data: { data: {
status: StepExecutionStatus.COMPLETED, status: StepExecutionStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
output: result ? (result as Prisma.InputJsonValue) : undefined, output: result ? toPrismaJson(result) : undefined,
}, },
}); });
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
data: { data: {
status: StepExecutionStatus.COMPLETED, status: StepExecutionStatus.COMPLETED,
completedAt: new Date(), completedAt: new Date(),
output: { output: toPrismaJson({
eventName, eventName,
eventData: data ? (data as Prisma.InputJsonValue) : undefined, eventData: data ? toPrismaJson(data) : undefined,
receivedAt: new Date().toISOString(), receivedAt: new Date().toISOString(),
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -543,6 +545,7 @@ export class WorkflowExecutionService {
: {}; : {};
const variables = { const variables = {
id: contact.id,
email: contact.email, email: contact.email,
...contactData, ...contactData,
...executionContext, ...executionContext,
@@ -866,7 +869,7 @@ export class WorkflowExecutionService {
await prisma.contact.update({ await prisma.contact.update({
where: {id: contact.id}, where: {id: contact.id},
data: { 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 {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 {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
@@ -9,34 +12,16 @@ import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.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 { export class WorkflowService {
/** /**
* Get all workflows for a project with pagination * 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 skip = (page - 1) * pageSize;
const where: Prisma.WorkflowWhereInput = { const where: Prisma.WorkflowWhereInput = {
@@ -70,7 +55,7 @@ export class WorkflowService {
]); ]);
return { return {
workflows: workflows as Workflow[], data: workflows as Workflow[],
total, total,
page, page,
pageSize, pageSize,
@@ -320,8 +305,8 @@ export class WorkflowService {
workflowId, workflowId,
type: data.type, type: data.type,
name: data.name, name: data.name,
position: data.position as Prisma.InputJsonValue, position: toPrismaJson(data.position),
config: data.config as Prisma.InputJsonValue, config: toPrismaJson(data.config),
templateId: data.templateId, templateId: data.templateId,
}, },
}); });
@@ -402,8 +387,8 @@ export class WorkflowService {
const updateData: Prisma.WorkflowStepUpdateInput = {}; const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name; if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue; if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue; if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
if (data.templateId !== undefined) { if (data.templateId !== undefined) {
if (data.templateId === null) { if (data.templateId === null) {
updateData.template = {disconnect: true}; updateData.template = {disconnect: true};
@@ -595,7 +580,7 @@ export class WorkflowService {
fromStepId: data.fromStepId, fromStepId: data.fromStepId,
condition: { condition: {
path: ['branch'], path: ['branch'],
equals: conditionObj.branch as Prisma.InputJsonValue, equals: toPrismaJson(conditionObj.branch),
}, },
}, },
}); });
@@ -786,7 +771,7 @@ export class WorkflowService {
// Start executing the workflow asynchronously // Start executing the workflow asynchronously
// Don't await - let it run in background // Don't await - let it run in background
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => { WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
console.error('Error executing workflow:', error); signale.error('Error executing workflow:', error);
}); });
return execution; 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 {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService'; import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService'; import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis'; 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', () => { describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string; let projectId: string;
let contactId: string; let contactId: string;
@@ -298,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
data: {billingLimitCampaigns: 10}, 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(); const lastMonth = new Date();
lastMonth.setDate(1);
lastMonth.setMonth(lastMonth.getMonth() - 1); lastMonth.setMonth(lastMonth.getMonth() - 1);
await prisma.email.create({ await prisma.email.create({
@@ -1,8 +1,18 @@
import {describe, it, expect, beforeEach} from 'vitest'; import {beforeEach, describe, expect, it, vi} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignService} from '../CampaignService'; import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; 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', () => { describe('CampaignService', () => {
let projectId: string; let projectId: string;
const prisma = getPrismaClient(); const prisma = getPrismaClient();
@@ -154,7 +164,7 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10}); 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.total).toBe(25);
expect(result.totalPages).toBe(3); expect(result.totalPages).toBe(3);
expect(result.page).toBe(1); expect(result.page).toBe(1);
@@ -167,8 +177,8 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT}); const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.campaigns).toHaveLength(2); expect(result.data).toHaveLength(2);
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true); expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
}); });
}); });
@@ -356,4 +366,227 @@ describe('CampaignService', () => {
expect(matching).toBe(5); 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 {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -439,4 +439,266 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
expect(unsubscribed?.subscribed).toBe(false); 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 {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js'; import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js'; import {HttpException} from '../../exceptions/index.js';
@@ -110,9 +110,7 @@ describe('DomainService', () => {
it('should throw error for invalid email format', async () => { it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i, /invalid email format/i,
@@ -122,13 +120,13 @@ describe('DomainService', () => {
it('should throw error when domain is not registered', async () => { it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), HttpException,
).rejects.toThrow(HttpException); );
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not registered/i,
).rejects.toThrow(/not registered/i); );
}); });
it('should throw error when domain belongs to different project', async () => { it('should throw error when domain belongs to different project', async () => {
@@ -141,13 +139,11 @@ describe('DomainService', () => {
data: {verified: true}, data: {verified: true},
}); });
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(HttpException);
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project2.id), /belongs to a different project/i,
).rejects.toThrow(/belongs to a different project/i); );
}); });
it('should throw error when domain is not verified', async () => { it('should throw error when domain is not verified', async () => {
@@ -155,13 +151,11 @@ describe('DomainService', () => {
await DomainService.addDomain(project.id, 'unverified.com'); await DomainService.addDomain(project.id, 'unverified.com');
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not verified/i,
).rejects.toThrow(/not verified/i); );
}); });
it('should return domain when all checks pass', async () => { it('should return domain when all checks pass', async () => {
@@ -358,9 +352,9 @@ describe('DomainService', () => {
}); });
it('should throw error for non-existent domain', async () => { it('should throw error for non-existent domain', async () => {
await expect( await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'), /domain not found/i,
).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(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
/used in.*template/i,
);
}); });
it('should throw error when domain is used in active campaigns', async () => { 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(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow( await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
/used in.*campaign/i,
);
}); });
it('should allow removal when campaign is SENT (completed)', async () => { 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 () => { it('should throw error for non-existent domain', async () => {
await expect( await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'), /domain not found/i,
).rejects.toThrow(/domain not found/i); );
}); });
it('should check usage in multiple templates', async () => { it('should check usage in multiple templates', async () => {
@@ -492,17 +482,15 @@ describe('DomainService', () => {
expect(result.domain).toBe('mail.example.com'); expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail // Different subdomain should fail
await expect( await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
DomainService.verifyEmailDomain('[email protected]', project.id), /not registered/i,
).rejects.toThrow(/not registered/i); );
}); });
it('should handle email with no @ sign', async () => { it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
/invalid email format/i,
);
}); });
it('should handle email with multiple @ signs', async () => { it('should handle email with multiple @ signs', async () => {
@@ -516,9 +504,7 @@ describe('DomainService', () => {
it('should handle empty email string', async () => { it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject(); const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow( await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
/invalid email format/i,
);
}); });
}); });
@@ -537,11 +523,7 @@ describe('DomainService', () => {
]); ]);
expect(results).toHaveLength(3); expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([ expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
}); });
it('should handle concurrent ownership checks', async () => { 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 {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared'; import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService'; import {EmailService} from '../EmailService';
@@ -669,7 +669,6 @@ describe('EmailService', () => {
// ======================================== // ========================================
describe('Attachment Schema Validation', () => { describe('Attachment Schema Validation', () => {
it('should validate attachment count limit (max 10)', () => { it('should validate attachment count limit (max 10)', () => {
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({ const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
filename: `file${i}.txt`, filename: `file${i}.txt`,
content: Buffer.from('content').toString('base64'), content: Buffer.from('content').toString('base64'),
@@ -690,7 +689,6 @@ describe('EmailService', () => {
}); });
it('should validate attachment size limit (10MB total)', () => { it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit // Exceeds ~13.3M base64 chars limit
const largeContent = 'A'.repeat(14000000); const largeContent = 'A'.repeat(14000000);
@@ -711,11 +709,11 @@ describe('EmailService', () => {
}); });
it('should accept attachments within size limit', () => { it('should accept attachments within size limit', () => {
const validContent = Buffer.from('Small file content').toString('base64'); const validContent = Buffer.from('Small file content').toString('base64');
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
attachments: [ attachments: [
@@ -731,7 +729,6 @@ describe('EmailService', () => {
}); });
it('should reject attachment with missing required fields', () => { it('should reject attachment with missing required fields', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
subject: 'Test', subject: 'Test',
@@ -748,7 +745,6 @@ describe('EmailService', () => {
}); });
it('should reject attachment with empty filename', () => { it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
subject: 'Test', subject: 'Test',
@@ -766,7 +762,6 @@ describe('EmailService', () => {
}); });
it('should reject attachment with filename exceeding 255 chars', () => { it('should reject attachment with filename exceeding 255 chars', () => {
const tooLongFilename = 'a'.repeat(256) + '.pdf'; const tooLongFilename = 'a'.repeat(256) + '.pdf';
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
@@ -786,18 +781,12 @@ describe('EmailService', () => {
}); });
it('should accept valid attachment with various content types', () => { 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) { for (const contentType of contentTypes) {
const result = ActionSchemas.send.safeParse({ const result = ActionSchemas.send.safeParse({
to: '[email protected]', to: '[email protected]',
from: '[email protected]',
subject: 'Test', subject: 'Test',
body: 'Test', body: 'Test',
attachments: [ attachments: [
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db'; import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
import {EventService} from '../EventService'; import {EventService} from '../EventService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues // Mock Redis for caching tests - must be inline to avoid hoisting issues
@@ -503,7 +504,7 @@ describe('EventService', () => {
const {redis} = await import('../../database/redis'); const {redis} = await import('../../database/redis');
// Set cache // Set cache
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
await redis.set(cacheKey, JSON.stringify([{id: 'test'}])); await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
// Verify cache exists // 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id); expect(result.data[0].id).toBe(subscribed.id);
}); });
it('should filter contacts by custom data fields', async () => { it('should filter contacts by custom data fields', async () => {
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id); expect(result.data[0].id).toBe(proUser.id);
}); });
it('should filter contacts with multiple conditions', async () => { it('should filter contacts with multiple conditions', async () => {
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id); expect(result.data[0].id).toBe(target.id);
}); });
it('should support notEquals operator', async () => { it('should support notEquals operator', async () => {
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id); expect(result.data[0].id).toBe(pro.id);
}); });
it('should support contains operator for strings', async () => { it('should support contains operator for strings', async () => {
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id); expect(result.data[0].id).toBe(match.id);
}); });
it('should support exists operator for custom fields', async () => { it('should support exists operator for custom fields', async () => {
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id); expect(result.data[0].id).toBe(withField.id);
}); });
it('should handle empty segments', async () => { it('should handle empty segments', async () => {
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
expect(result.total).toBe(0); expect(result.total).toBe(0);
}); });
}); });
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2); expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should support pagination', async () => { it('should support pagination', async () => {
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
}); });
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10); 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.total).toBe(25);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10); 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); 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 // Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id); let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
// Update contact to pro plan // Update contact to pro plan
await prisma.contact.update({ await prisma.contact.update({
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
// Should now be in segment // Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id); result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id); expect(result.data[0].id).toBe(contact.id);
}); });
it('should be removed from segment when criteria no longer met', async () => { it('should be removed from segment when criteria no longer met', async () => {
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
// Initially in segment // Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id); let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1); expect(result.data).toHaveLength(1);
// Unsubscribe contact // Unsubscribe contact
await prisma.contact.update({ await prisma.contact.update({
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
// Should no longer be in segment // Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id); 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); const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort()); expect(result.data.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)).not.toContain(match.id);
}); });
it('should support case-insensitive equals/contains for email strings', async () => { 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 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(lower.id);
expect(equalsIds).toContain(upper.id); expect(equalsIds).toContain(upper.id);
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
}); });
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id); 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(lower.id);
expect(containsIds).toContain(upper.id); expect(containsIds).toContain(upper.id);
}); });
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id); expect(ids).not.toContain(subscribed.id);
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
}); });
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id); 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).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id); expect(notContainsIds).not.toContain(acme.id);
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
}); });
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id); 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).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id); expect(notEqualsIds).not.toContain(acme.id);
}); });
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
}); });
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id); 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(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false); expect(existsIds.has(withNullCompany.id)).toBe(false);
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
}); });
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id); 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(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true); expect(notExistsIds.has(withNullCompany.id)).toBe(true);
}); });
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
}); });
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id); 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(mid.id);
expect(gtIds).toContain(high.id); expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id); expect(gtIds).not.toContain(low.id);
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
}); });
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id); 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(low.id);
expect(lteIds).toContain(mid.id); expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id); expect(lteIds).not.toContain(high.id);
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
}); });
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id); 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).toContain(newer.id);
expect(gtIds).not.toContain(older.id); expect(gtIds).not.toContain(older.id);
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
}); });
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id); 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(older.id);
expect(lteIds).toContain(newer.id); expect(lteIds).toContain(newer.id);
}); });
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
}); });
const result = await SegmentService.getContacts(projectId, segment.id); 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); 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 {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService'; import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
} }
const page1 = await TemplateService.list(projectId, 1, 10); 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.total).toBe(25);
expect(page1.page).toBe(1); expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10); expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10); const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10); expect(page2.data).toHaveLength(10);
expect(page2.page).toBe(2); expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10); const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5); expect(page3.data).toHaveLength(5);
expect(page3.page).toBe(3); expect(page3.page).toBe(3);
}); });
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome'); const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2); 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 () => { it('should filter templates by search query (description)', async () => {
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'new'); const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2); 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')]), expect.arrayContaining([expect.stringContaining('new')]),
); );
}); });
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING); const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2); 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); const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1); 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 () => { 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); const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1); 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 () => { 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); const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest expect(result.data[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id); expect(result.data[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest expect(result.data[2].id).toBe(template1.id); // Oldest
}); });
it('should only return templates for the specified project', async () => { it('should only return templates for the specified project', async () => {
@@ -1,5 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest'; 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 {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Premium Status', name: 'Check Premium Status',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.isPremium', field: 'data.isPremium',
operator: 'equals', operator: 'equals',
value: true, value: true,
}, }),
}, },
}); });
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Premium Path', name: 'Premium Path',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.EXIT,
name: 'Standard Path', name: 'Standard Path',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {reason: 'Standard customer'}, config: toPrismaJson({reason: 'Standard customer'}),
}, },
}); });
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: yesStep.id, toStepId: yesStep.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
priority: 1, priority: 1,
}, },
}); });
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
priority: 2, priority: 2,
}, },
}); });
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Premium', name: 'Check Premium',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.isPremium', field: 'data.isPremium',
operator: 'equals', operator: 'equals',
value: true, value: true,
}, }),
}, },
}); });
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Premium', name: 'Premium',
position: {x: 200, y: -50}, position: {x: 200, y: -50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Standard', name: 'Standard',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: yesStep.id, toStepId: yesStep.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Country', name: 'Check Country',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.CONDITION,
name: 'Check Premium (US)', name: 'Check Premium (US)',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.EXIT,
name: 'US Premium', name: 'US Premium',
position: {x: 300, y: -75}, position: {x: 300, y: -75},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'US Standard', name: 'US Standard',
position: {x: 300, y: -25}, position: {x: 300, y: -25},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Non-US', name: 'Non-US',
position: {x: 200, y: 50}, position: {x: 200, y: 50},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition1.id, fromStepId: condition1.id,
toStepId: condition2.id, toStepId: condition2.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition1.id, fromStepId: condition1.id,
toStepId: nonUsExit.id, toStepId: nonUsExit.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition2.id, fromStepId: condition2.id,
toStepId: usPremiumExit.id, toStepId: usPremiumExit.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition2.id, fromStepId: condition2.id,
toStepId: usStandardExit.id, toStepId: usStandardExit.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT, type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Purchase', name: 'Wait for Purchase',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
eventName: 'purchase.completed', eventName: 'purchase.completed',
timeout: 3600, // 1 hour timeout: 3600, // 1 hour
}, }),
}, },
}); });
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Complete', name: 'Complete',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT, type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Event', name: 'Wait for Event',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
eventName: 'user.verified', eventName: 'user.verified',
timeout: 3600, timeout: 3600,
}, }),
}, },
}); });
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY, type: WorkflowStepType.DELAY,
name: 'Wait 1 day', name: 'Wait 1 day',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.CONDITION,
name: 'Check Status', name: 'Check Status',
position: {x: 200, y: 0}, 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, type: WorkflowStepType.EXIT,
name: 'Complete', name: 'Complete',
position: {x: 300, y: 0}, position: {x: 300, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: exit.id, toStepId: exit.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'A/B Split', name: 'A/B Split',
position: {x: 100, y: 0}, 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, type: WorkflowStepType.DELAY,
name: 'Path A Delay', name: 'Path A Delay',
position: {x: 200, y: -50}, 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, type: WorkflowStepType.DELAY,
name: 'Path B Delay', name: 'Path B Delay',
position: {x: 200, y: 50}, 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, type: WorkflowStepType.EXIT,
name: 'Merge Point', name: 'Merge Point',
position: {x: 300, y: 0}, position: {x: 300, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: pathA.id, toStepId: pathA.id,
condition: {branch: 'yes'}, condition: toPrismaJson({branch: 'yes'}),
}, },
}); });
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: pathB.id, toStepId: pathB.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Bad Condition', name: 'Bad Condition',
position: {x: 100, y: 0}, 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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check Missing Field', name: 'Check Missing Field',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'data.nonExistentField', field: 'data.nonExistentField',
operator: 'equals', operator: 'equals',
value: 'something', value: 'something',
}, }),
}, },
}); });
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Exit', name: 'Exit',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {}, config: toPrismaJson({}),
}, },
}); });
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: condition.id, fromStepId: condition.id,
toStepId: noStep.id, toStepId: noStep.id,
condition: {branch: 'no'}, condition: toPrismaJson({branch: 'no'}),
}, },
}); });
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Early Exit', name: 'Early Exit',
position: {x: 100, y: 0}, 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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: {}, context: toPrismaJson({}),
}, },
}); });
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.TRIGGER, type: WorkflowStepType.TRIGGER,
name: 'Start', name: 'Start',
position: {x: 0, y: 0}, position: {x: 0, y: 0},
config: {}, config: toPrismaJson({}),
}); });
const exitStep = await factories.createWorkflowStep({ const exitStep = await factories.createWorkflowStep({
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'End', name: 'End',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: {}, config: toPrismaJson({}),
}); });
await prisma.workflowTransition.create({ await prisma.workflowTransition.create({
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id, currentStepId: triggerStep.id,
context: contextData as Prisma.InputJsonValue, context: toPrismaJson(contextData),
}, },
}); });
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check if first open', name: 'Check if first open',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.isFirstOpen', field: 'event.isFirstOpen',
operator: 'equals', operator: 'equals',
value: true, // Use boolean, not string value: true, // Use boolean, not string
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'First Open', name: 'First Open',
position: {x: 200, y: 0}, 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, type: WorkflowStepType.EXIT,
name: 'Not First Open', name: 'Not First Open',
position: {x: 200, y: 100}, 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}, data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
}); });
await prisma.workflowTransition.create({ 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({ 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 // Create execution with event data
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome Email', subject: 'Welcome Email',
from: '[email protected]', from: '[email protected]',
isFirstOpen: true, isFirstOpen: true,
openedAt: new Date().toISOString(), openedAt: new Date().toISOString(),
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check subject', name: 'Check subject',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.subject', field: 'event.subject',
operator: 'contains', operator: 'contains',
value: 'Welcome', value: 'Welcome',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {reason: 'matched'} as Prisma.InputJsonValue, config: toPrismaJson({reason: 'matched'}),
}, },
}); });
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: exitStep.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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome to Plunk!', subject: 'Welcome to Plunk!',
from: '[email protected]', from: '[email protected]',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION, type: WorkflowStepType.CONDITION,
name: 'Check opens count', name: 'Check opens count',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
field: 'event.opens', field: 'event.opens',
operator: 'greaterThan', operator: 'greaterThan',
value: '3', value: '3',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT, type: WorkflowStepType.EXIT,
name: 'Done', name: 'Done',
position: {x: 200, y: 0}, position: {x: 200, y: 0},
config: {reason: 'engaged'} as Prisma.InputJsonValue, config: toPrismaJson({reason: 'engaged'}),
}, },
}); });
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: { data: {
fromStepId: conditionStep.id, fromStepId: conditionStep.id,
toStepId: exitStep.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, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Newsletter', subject: 'Newsletter',
opens: 5, opens: 5,
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WEBHOOK, type: WorkflowStepType.WEBHOOK,
name: 'Send Webhook', name: 'Send Webhook',
position: {x: 100, y: 0}, position: {x: 100, y: 0},
config: { config: toPrismaJson({
url: 'https://webhook.example.com/test', url: 'https://webhook.example.com/test',
method: 'POST', method: 'POST',
} as Prisma.InputJsonValue, }),
}, },
}); });
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id, contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING, status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id, currentStepId: triggerStep!.id,
context: { context: toPrismaJson({
subject: 'Welcome Email', subject: 'Welcome Email',
from: '[email protected]', from: '[email protected]',
messageId: 'msg-123', messageId: 'msg-123',
isFirstOpen: true, isFirstOpen: true,
openedAt: '2024-01-15T10:00:00Z', 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 { import {
WorkflowStepType,
StepExecutionStatus, StepExecutionStatus,
WorkflowExecutionStatus,
TemplateType, TemplateType,
WorkflowExecutionStatus,
WorkflowStepType,
WorkflowTriggerType, WorkflowTriggerType,
} from '@plunk/db'; } from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService'; import {WorkflowExecutionService} from '../WorkflowExecutionService';
@@ -1,6 +1,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db'; import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowService} from '../WorkflowService'; import {WorkflowService} from '../WorkflowService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers'; import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues // 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 () => { it('should invalidate cache when creating enabled workflow', async () => {
const {redis} = await import('../../database/redis'); const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
// Set cache // Set cache
await redis.set(cacheKey, JSON.stringify([{id: 'old'}])); await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
@@ -204,7 +205,7 @@ describe('WorkflowService', () => {
const page1 = await WorkflowService.list(projectId, 1, 10); 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.total).toBe(25);
expect(page1.totalPages).toBe(3); expect(page1.totalPages).toBe(3);
}); });
@@ -217,7 +218,7 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId, 1, 20, 'welcome'); const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2); 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 () => { it('should include step and execution counts', async () => {
@@ -233,8 +234,8 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId); const result = await WorkflowService.list(projectId);
const found = result.workflows.find(w => w.id === workflow.id) as const found = result.data.find(w => w.id === workflow.id) as
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}}) | ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
| undefined; | undefined;
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
expect(found?._count.executions).toBe(1); expect(found?._count.executions).toBe(1);
@@ -286,7 +287,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when enabling workflow', async () => { it('should invalidate cache when enabling workflow', async () => {
const {redis} = await import('../../database/redis'); const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({ const workflow = await factories.createWorkflow({
projectId, projectId,
@@ -326,7 +327,7 @@ describe('WorkflowService', () => {
it('should invalidate cache when deleting enabled workflow', async () => { it('should invalidate cache when deleting enabled workflow', async () => {
const {redis} = await import('../../database/redis'); const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`; const cacheKey = Keys.Workflow.enabled(projectId);
const workflow = await factories.createWorkflow({ const workflow = await factories.createWorkflow({
projectId, projectId,
+80
View File
@@ -6,6 +6,18 @@ export const Keys = {
email(email: string): string { email(email: string): string {
return `account:${email}`; 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: { Domain: {
id(id: string): string { id(id: string): string {
@@ -14,5 +26,73 @@ export const Keys = {
project(projectId: string): string { project(projectId: string): string {
return `domain:project:${projectId}`; 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; } as const;
+2 -2
View File
@@ -34,8 +34,8 @@ export function buildEmailFieldsUpdate(data: {
subject?: string; subject?: string;
body?: string; body?: string;
from?: string; from?: string;
fromName?: string; fromName?: string | null;
replyTo?: string; replyTo?: string | null;
}): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput { }): Prisma.CampaignUpdateInput | Prisma.TemplateUpdateInput {
return buildUpdateData(data) as 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} */ /** @type {import('next-sitemap').IConfig} */
module.exports = { 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, generateRobotsTxt: true,
}; };
+15 -3
View File
@@ -14,11 +14,23 @@
"@plunk/db": "*", "@plunk/db": "*",
"@plunk/shared": "*", "@plunk/shared": "*",
"@plunk/ui": "*", "@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", "lucide-react": "^0.553.0",
"next": "^16.0.7", "next": "^16.0.10",
"next-seo": "^6.6.0", "next-seo": "^6.6.0",
"react": "19.2.1", "react": "19.2.3",
"react-dom": "19.2.1", "react-dom": "19.2.3",
"sonner": "^2.0.6" "sonner": "^2.0.6"
}, },
"devDependencies": { "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> </Link>
</li> </li>
<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 Made by humans
</Link> </Link>
</li> </li>
@@ -131,9 +134,7 @@ export default function Footer() {
</div> </div>
<div className="mt-16 border-t border-neutral-200 pt-8"> <div className="mt-16 border-t border-neutral-200 pt-8">
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">&copy; {new Date().getFullYear()} Plunk. All rights reserved.</p>
&copy; {new Date().getFullYear()} Plunk. All rights reserved.
</p>
</div> </div>
</div> </div>
</footer> </footer>
+36 -18
View File
@@ -12,7 +12,7 @@ export default function Navbar() {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
return ( 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={'z-40 py-6'}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-12"> <div className="flex items-center gap-12">
@@ -25,19 +25,34 @@ export default function Navbar() {
</Link> </Link>
</div> </div>
<div className="hidden items-center gap-8 md:flex"> <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 By humans
</Link> </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 Pricing
</Link> </Link>
<Link
href={'/guides'}
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
>
Guides
</Link>
<Link <Link
href={WIKI_URI} href={WIKI_URI}
target={'_blank'} target={'_blank'}
rel={'noreferrer'} 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 Docs
<svg className={'h-3.5 w-3.5'} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <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" 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" 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 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19.25 9.25V4.75H14.75" />
strokeLinecap="round" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 5L11.75 12.25" />
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> </svg>
</Link> </Link>
</div> </div>
</div> </div>
<div className="hidden items-center gap-6 md:flex"> <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 Sign in
</a> </a>
<motion.a <motion.a
whileHover={{scale: 1.02}} whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}} whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`} 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 Get started
</motion.a> </motion.a>
@@ -146,6 +156,14 @@ export default function Navbar() {
Pricing Pricing
</Link> </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 <a
href={WIKI_URI} href={WIKI_URI}
target={'_blank'} 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 './Navbar';
export * from './Footer'; 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);
}

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