Compare commits

..
111 Commits
Author SHA1 Message Date
Dries Augustyns a00473314f Merge pull request #255 from useplunk/release-please--branches--next--components--plunk
chore(next): release 0.5.0
2026-02-17 19:16:13 +01:00
github-actions[bot] 2dabcb2419 chore(next): release 0.5.0 2026-02-17 18:09:45 +00:00
Dries Augustyns d9ae171cc7 chore: fix linting errors 2026-02-17 19:09:01 +01:00
Dries Augustyns e4e334c77a fix: Center "Add Step" nodes below parent nodes and update positions on drag 2026-02-17 19:06:22 +01:00
Dries Augustyns 78d3d224af feat: Add support for custom email recipients in workflow steps 2026-02-17 18:57:15 +01:00
Dries Augustyns f9b1354460 fix: Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings 2026-02-17 18:19:58 +01:00
Dries Augustyns 95e5ec8485 Merge pull request #290 from useplunk/inbound
Inbound
2026-02-17 16:56:29 +01:00
Dries Augustyns d050b55baa feat: Add inbound handling 2026-02-17 16:52:56 +01:00
Dries Augustyns 7964563b62 feat: Add advanced DNS configuration 2026-02-17 16:33:34 +01:00
Dries Augustyns e01dc4066c Merge pull request #289 from useplunk/inbound
feat: Add initial handling in webhook for inbound
2026-02-17 16:07:52 +01:00
Dries Augustyns c286f49097 feat: Add initial handling in webhook for inbound 2026-02-17 16:03:50 +01:00
Dries Augustyns 4c81d9ec04 feat: add documentation link and redirect to WIKI_URI 2026-02-17 10:30:17 +01:00
Dries Augustyns a928666dfc fix: Update contact subscription logic for upsert operations 2026-02-17 10:25:01 +01:00
Dries Augustyns d55dda3127 feat: add minimum thresholds for bounce and complaint rates 2026-02-16 11:03:54 +01:00
Dries Augustyns d637147f25 Merge pull request #284 from useplunk/dependabot/npm_and_yarn/webpack-5.105.0
chore(deps): bump webpack from 5.103.0 to 5.105.0
2026-02-16 10:20:22 +01:00
Dries Augustyns c1ed23c967 Merge pull request #286 from useplunk/dependabot/npm_and_yarn/markdown-it-14.1.1
chore(deps): bump markdown-it from 14.1.0 to 14.1.1
2026-02-16 10:20:08 +01:00
Dries Augustyns 46001619e8 Merge pull request #287 from useplunk/dependabot/npm_and_yarn/qs-6.14.2
chore(deps): bump qs from 6.14.1 to 6.14.2
2026-02-16 10:19:52 +01:00
Dries Augustyns 7a6df7760b Merge pull request #288 from bpotmalnik/feat/add-polish
feat(i18n): add Polish locale translations
2026-02-16 10:00:38 +01:00
Bart Potmalnik d2d779c68f feat(i18n): add Polish locale translations
Add polish translations

Ref #246
2026-02-15 21:43:29 +01:00
dependabot[bot] bbc12e0029 chore(deps): bump qs from 6.14.1 to 6.14.2
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.14.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-14 12:57:41 +00:00
dependabot[bot] 125718bb94 chore(deps): bump markdown-it from 14.1.0 to 14.1.1
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.1.0 to 14.1.1.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.1.0...14.1.1)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-13 20:46:34 +00:00
Dries Augustyns 35d67c6b42 Merge pull request #285 from yumhum/i18n-czech-locale 2026-02-11 08:39:37 +01:00
Jan Wunsch 14c471e4ca feat(i18n): add Czech locale translations
Add Czech (cs) translations for contact-facing pages and email footers,
and wire up the locale in the i18n system with the correct flag.
2026-02-11 01:48:38 +01:00
Dries Augustyns 42ceb6edc0 refactor: remove unnecessary logging for segment processing 2026-02-10 20:23:31 +01:00
Dries Augustyns 5bce1d74ff docs: add webhooks documentation for real-time event handling 2026-02-09 19:01:16 +01:00
Dries Augustyns b4404f698e docs: update contacts documentation to include subscription state and email delivery rules 2026-02-09 18:45:01 +01:00
dependabot[bot] e4625894c9 chore(deps): bump webpack from 5.103.0 to 5.105.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.103.0 to 5.105.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.103.0...v5.105.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.105.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-06 08:04:36 +00:00
Dries Augustyns af87d80baa Merge pull request #280 from useplunk/dependabot/npm_and_yarn/next-16.1.6
chore(deps): bump next from 16.0.1 to 16.1.6
2026-02-06 09:01:56 +01:00
Dries Augustyns 1f84c9113c Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-06 09:01:33 +01:00
Dries Augustyns 0cf0a26a97 feat: Integrate NuqsAdapter for improved state management and query handling 2026-02-06 09:01:25 +01:00
Dries Augustyns 0d4b694208 fix: Implement merging for activity updates to preserve component state 2026-02-06 08:55:24 +01:00
dependabot[bot] 20ffb2b8bc chore(deps): bump next from 16.0.1 to 16.1.6
Bumps [next](https://github.com/vercel/next.js) from 16.0.1 to 16.1.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.1...v16.1.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-29 14:12:13 +00:00
Dries Augustyns 0af79eb84b Merge pull request #279 from useplunk/dependabot/npm_and_yarn/tar-7.5.7 2026-01-29 15:10:06 +01:00
Dries Augustyns e2e98149cb Merge pull request #278 from useplunk/dependabot/npm_and_yarn/next-16.1.5 2026-01-29 15:09:44 +01:00
Dries Augustyns f55ab31ae5 Merge pull request #277 from useplunk/dependabot/npm_and_yarn/lodash-4.17.23 2026-01-29 15:09:24 +01:00
Dries Augustyns 61a65d01c7 Merge pull request #275 from useplunk/dependabot/npm_and_yarn/diff-4.0.4 2026-01-29 15:09:05 +01:00
dependabot[bot] 7aa3b74c6f chore(deps): bump tar from 7.5.3 to 7.5.7
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.3 to 7.5.7.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.7)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-29 14:02:08 +00:00
dependabot[bot] 7647109fb6 chore(deps): bump next from 16.0.10 to 16.1.5
Bumps [next](https://github.com/vercel/next.js) from 16.0.10 to 16.1.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.10...v16.1.5)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-27 20:11:43 +00:00
Dries Augustyns ad0c17da56 fix: Enhance email activity filtering by adding date range checks 2026-01-26 09:24:49 +01:00
dependabot[bot] 93ca6b8d94 chore(deps): bump lodash from 4.17.21 to 4.17.23
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-23 06:43:35 +00:00
Dries Augustyns c85df75d53 feat: Add bounce and complaint filter to activity feed 2026-01-21 20:52:23 +01:00
Dries Augustyns e16093a264 chore: Increaes complaint thresholds 2026-01-21 20:46:33 +01:00
dependabot[bot] a54c8139b1 chore(deps): bump diff from 4.0.2 to 4.0.4
Bumps [diff](https://github.com/kpdecker/jsdiff) from 4.0.2 to 4.0.4.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](https://github.com/kpdecker/jsdiff/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-20 22:25:03 +00:00
Dries Augustyns 8de79e37e9 Merge pull request #269 from useplunk/dependabot/npm_and_yarn/undici-6.23.0 2026-01-17 20:28:36 +01:00
Dries Augustyns c662ecdbb2 Merge pull request #274 from useplunk/dependabot/npm_and_yarn/tar-7.5.3 2026-01-17 20:28:17 +01:00
dependabot[bot] 8f6f63e572 chore(deps): bump tar from 7.5.2 to 7.5.3
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.2 to 7.5.3.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.2...v7.5.3)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-17 15:52:37 +00:00
Dries Augustyns 22c22aba1b Merge pull request #273 from wettiemans/fix/242-template-type-dropdown 2026-01-17 12:40:18 +01:00
Dries AugustynsandCopilot de106f9d91 Update apps/web/src/pages/templates/[id].tsx
Co-authored-by: Copilot <[email protected]>
2026-01-17 12:30:00 +01:00
Jan Behrends 8a38766c02 fix: Ensure template type is loaded before rendering Select
Addresses #242. The Select component renders with value={undefined}
before useEffect populates editedTemplate.type. This triggers a React
warning: 'Select is changing from uncontrolled to controlled.'

On the hosted environment, this likely causes Radix UI's data-placeholder
attribute to remain set on the trigger, with the selected value text
not being rendered despite the value being loaded in state.

This change extends the loading condition to wait for editedTemplate.type,
ensuring the Select never renders in an undefined/uncontrolled state.
2026-01-16 13:09:14 +01:00
Dries Augustyns 3a16ad3221 chore: Add bundled notification for segment membership updates 2026-01-15 19:47:12 +01:00
Dries Augustyns ec402cb9b3 Merge pull request #270 from mmihalev/next 2026-01-15 13:06:29 +01:00
Milen Mihalev 663bc2be8d feat(i18n): Add Bulgarian translations 2026-01-15 11:50:25 +02:00
Dries Augustyns df28e2dc7d Merge pull request #268 from marcelscruz/next 2026-01-15 09:40:52 +01:00
dependabot[bot] 79fe9e94c7 chore(deps): bump undici from 6.22.0 to 6.23.0
Bumps [undici](https://github.com/nodejs/undici) from 6.22.0 to 6.23.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-15 00:25:59 +00:00
Marcel Cruz c2afb2dfab feat(i18n): Add Portuguese translations 2026-01-15 00:32:37 +01:00
Dries Augustyns 0d1ed0ec67 Merge pull request #263 from 5h0ov/new-olderthan-segmentfilter 2026-01-13 10:30:56 +01:00
Dries Augustyns 99526ebfc2 chore: Rename within field label to align with new older than 2026-01-13 10:25:10 +01:00
Dries Augustyns e6baace00d feat: Remove domain from AWS if no longer in use by other projects 2026-01-13 10:19:54 +01:00
Dries Augustyns 743155b06a chore: Add missing variables to turbo.json 2026-01-12 20:15:36 +01:00
Dries Augustyns 0a67a8278f feat: Add additional checks for website, NS records and personal emails 2026-01-12 20:02:08 +01:00
Dries Augustyns 26800a8553 fix: Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation 2026-01-12 18:49:30 +01:00
Dries Augustyns 93165644af feat: Ability to disable signups and disable email verification for self-hosters 2026-01-12 15:20:14 +01:00
Dries Augustyns 4db1ccc3fc fix: Refactor Redis keys to prevent multiple messages on concurrent requests 2026-01-12 08:33:51 +01:00
Dries Augustyns 06c4d644b7 Merge pull request #262 from 5h0ov/development-enhancements 2026-01-12 08:07:47 +01:00
Shuvadipta Das b5fa21a57c feat: add "olderThan" segment filter operator
- Added `olderThan` and `triggeredOlderThan` operators to types and schemas
- Implemented backend logic for `olderThan` operator in SegmentService
- Updated SegmentFilterBuilder and shared index/types files to support new time-based operators
2026-01-12 11:10:53 +05:30
Shuvadipta Das ddbe5237fe fix: added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api 2026-01-12 11:07:53 +05:30
Dries Augustyns 18e17cf198 chore: Remove default tag from issue template 2026-01-11 11:53:36 +01:00
Dries Augustyns 6a6772d5ed Update issue templates 2026-01-11 11:51:38 +01:00
Dries Augustyns 50042cea27 chore: Clean up variable replacement paths 2026-01-11 09:09:41 +01:00
Dries Augustyns 3ccf890a21 chore: Clean up variable replacement paths 2026-01-11 08:57:19 +01:00
Dries Augustyns d57f6c81cb chore: Clean up variable replacement paths 2026-01-11 08:37:01 +01:00
Dries Augustyns c07816c2a1 fix: Update URL replacement logic to handle runtime paths and add warnings for missing files 2026-01-11 08:26:59 +01:00
Dries Augustyns 73da0a5136 Merge pull request #257 from mmihalev/next 2026-01-10 10:20:57 +01:00
Milen Mihalev d12b797ebe Use environment variables for SMTP port configuration 2026-01-09 15:38:59 +02:00
Dries Augustyns 7df43d8553 fix: Improve bounce handling logic to differentiate between permanent and transient bounces 2026-01-09 11:48:20 +01:00
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
237 changed files with 6821 additions and 3809 deletions
+19 -4
View File
@@ -27,10 +27,10 @@ WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation # NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# Use placeholder URLs that will be replaced at Docker container runtime # Use placeholder URLs that will be replaced at Docker container runtime
NEXT_PUBLIC_API_URI=https://api.useplunk.com NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://docs.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
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
# Default: true # Default: true
# AUTO_PROJECT_DISABLE=false # AUTO_PROJECT_DISABLE=false
# ========================================
# OPTIONAL: Self-Hosting User Management
# ========================================
# Controls whether new user signups are allowed
# When enabled (true), the signup endpoint will reject new user registration attempts
# Useful for private instances or when you want to manually manage users
# Default: false
# DISABLE_SIGNUPS=false
# Controls whether email validation checks are performed on signup
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
# When disabled (false), skips these validation checks and allows any email format
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ======================================== # ========================================
# ADVANCED (rarely needed) # ADVANCED (rarely needed)
# ======================================== # ========================================
@@ -0,0 +1,21 @@
---
name: Feature requests
about: Use this template to suggest new features for Plunk
title: ''
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Alternatives or workarounds
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.
+61
View File
@@ -0,0 +1,61 @@
---
name: Product Issues
about: Use this template for bugs and issues on Plunk
title: ''
labels: bug
assignees: ''
---
## Description
## To Reproduce
## Expected behavior
## Environment
Please select the option that applies:
* Deployment type:
* [ ] Hosted
* [ ] Self-hosted
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
## Version verification
> ⚠️ **Important:**
> Self-hosted issues **must** be reproduced on the latest commit.
> Issues without a confirmed commit SHA may be closed without investigation.
* [ ] I am using the hosted version
**If self-hosted:**
* [ ] I have confirmed this issue still exists on the **latest commit** (not the `latest` tag)
* Commit SHA tested: `__________`
## Logs / Error output
If applicable, add logs, stack traces, or error messages here.
```
PASTE LOGS HERE
```
## Screenshots / Recordings / Additional context
---
## Checklist
Please confirm the following before submitting:
* [ ] I have searched existing issues to ensure this bug has not already been reported
* [ ] I have provided clear reproduction steps
* [ ] I have included all relevant environment details
+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
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.3.0" ".": "0.5.0"
} }
+1 -1
View File
@@ -2,7 +2,7 @@ nodeLinker: node-modules
# Support multiple architectures - download pre-built binaries instead of compiling # Support multiple architectures - download pre-built binaries instead of compiling
supportedArchitectures: supportedArchitectures:
os: [ "linux", "darwin" ] os: [ "linux", "darwin", "win32" ]
cpu: [ "x64", "arm64" ] cpu: [ "x64", "arm64" ]
libc: [ "glibc" ] libc: [ "glibc" ]
+81
View File
@@ -1,5 +1,86 @@
# Changelog # Changelog
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
### Features
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
### Bug Fixes
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
### Code Refactoring
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
### Documentation
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
### 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) ## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
+17 -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
@@ -147,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications) - Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like - Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
project disabled, billing limits, etc. If not set, only ntfy notifications are sent) project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
- Self-hosting User Management (optional):
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
plus-addressing, domain existence, and MX records
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
bounce/complaint rate thresholds are exceeded
**Important Notes:** **Important Notes:**
@@ -156,3 +163,10 @@ 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
+5 -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
@@ -273,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)
+1
View File
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
USE_HTTPS=false USE_HTTPS=false
API_URI=http://localhost:8080 API_URI=http://localhost:8080
WIKI_URI=http://localhost:1000
DASHBOARD_URI=http://localhost:3000 DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000 LANDING_URI=http://localhost:4000
@@ -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';
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
expect(result.data.subscribed).toBeUndefined(); expect(result.data.subscribed).toBeUndefined();
} }
}); });
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
const newEmail = '[email protected]';
// Send email to new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
// Transactional emails should create contacts as unsubscribed by default
expect(contact.subscribed).toBe(false);
});
}); });
describe('/v1/track endpoint', () => { describe('/v1/track endpoint', () => {
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
// Track event for new contact without specifying subscribed // Track event for new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js'); const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined); const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
// New contacts should default to subscribed=true // Event tracking should create contacts as subscribed by default
expect(contact.subscribed).toBe(true); expect(contact.subscribed).toBe(true);
}); });
@@ -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:
+88 -10
View File
@@ -20,7 +20,7 @@ import {
SMTP_ENABLED, SMTP_ENABLED,
STRIPE_ENABLED, STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED, TRACKING_TOGGLE_ENABLED,
WIKI_URI WIKI_URI,
} from './app/constants.js'; } from './app/constants.js';
import {Actions} from './controllers/Actions.js'; import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js'; import {Activity} from './controllers/Activity.js';
@@ -76,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'));
@@ -154,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 => ({
+6
View File
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Useful for self-hosters who want to manage project status manually // Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true'; export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// Self-hosting Configuration (optional)
// Controls whether new user signups are allowed (default: false)
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
// Controls whether email validation checks are performed on signup (default: false)
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
// Email Verification & Password Reset // Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
+7 -6
View File
@@ -1,8 +1,6 @@
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';
@@ -52,7 +50,7 @@ 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);
@@ -74,11 +72,12 @@ export class Actions {
// Create or update contact with persistent data only // Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields // ContactService.upsert will filter out non-persistent fields
// Event tracking should subscribe contacts by default
const contact = await ContactService.upsert( const contact = await ContactService.upsert(
auth.projectId, auth.projectId,
email, email,
data as Record<string, unknown> | undefined, data as Record<string, unknown> | undefined,
subscribed, subscribed ?? true,
); );
// Track the event with ALL data (persistent + non-persistent) // Track the event with ALL data (persistent + non-persistent)
@@ -173,7 +172,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} =
@@ -268,7 +267,8 @@ export class Actions {
: (data as Record<string, unknown> | undefined); : (data as Record<string, unknown> | undefined);
// Create or update contact with metadata // Create or update contact with metadata
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed); // Transactional emails should not subscribe contacts by default
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed ?? false);
// Get merged data including non-persistent fields for template rendering // Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined); const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
@@ -277,6 +277,7 @@ export class Actions {
// These are always available for template rendering // These are always available for template rendering
const dataWithSystemVars = { const dataWithSystemVars = {
...mergedData, ...mergedData,
id: contact.id,
email: contact.email, email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`, unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
+6 -7
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, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js'; import {ActivityService} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity') @Controller('activity')
@@ -24,7 +23,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -65,7 +64,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -85,7 +84,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @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);
@@ -117,7 +116,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified]) @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);
+4 -6
View File
@@ -1,7 +1,5 @@
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 type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -22,7 +20,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -44,7 +42,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -68,7 +66,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -92,7 +90,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified]) @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;
+38
View File
@@ -7,6 +7,7 @@ import * as React from 'react';
import { import {
DASHBOARD_URI, DASHBOARD_URI,
DISABLE_SIGNUPS,
EMAIL_VERIFICATION_RATE_LIMIT, EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW, EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED,
@@ -15,12 +16,14 @@ import {
PASSWORD_RESET_RATE_LIMIT, PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED, PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS, TOKEN_EXPIRY_SECONDS,
VERIFY_EMAIL_ON_SIGNUP,
} from '../app/constants.js'; } from '../app/constants.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js'; import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js'; import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.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';
@@ -62,8 +65,43 @@ export class Auth {
@Post('signup') @Post('signup')
@CatchAsync @CatchAsync
public async signup(req: Request, res: Response, _next: NextFunction) { public async signup(req: Request, res: Response, _next: NextFunction) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
return res.json({
success: false,
data: 'New user signups are currently disabled',
});
}
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 (if verification enabled)
if (VERIFY_EMAIL_ON_SIGNUP) {
const verification = await EmailVerificationService.verifyEmail(email);
if (
verification.isDisposable ||
verification.isPlusAddressed ||
!verification.domainExists ||
!verification.hasMxRecords
) {
// Build list of reasons for notification
const reasons: string[] = [];
if (verification.isDisposable) reasons.push('disposable email');
if (verification.isPlusAddressed) reasons.push('plus addressing');
if (!verification.domainExists) reasons.push('domain does not exist');
if (!verification.hasMxRecords) reasons.push('no MX records');
// Send notification about failed signup attempt
await NtfyService.notifyFailedSignupAttempt(email, reasons);
return res.json({
success: false,
data: 'This email address cannot be used for signup',
});
}
}
const user = await UserService.email(email); const user = await UserService.email(email);
if (user) { if (user) {
+11 -19
View File
@@ -4,7 +4,6 @@ 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, requireEmailVerified} 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';
@@ -20,11 +19,10 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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');
} }
@@ -63,7 +61,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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,
});
} }
/** /**
@@ -96,7 +88,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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!);
@@ -115,7 +107,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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;
@@ -161,7 +153,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!); await CampaignService.delete(auth.projectId, id!);
@@ -180,7 +172,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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!);
@@ -200,7 +192,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor; const scheduledFor = req.body?.scheduledFor;
@@ -231,7 +223,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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!);
@@ -251,7 +243,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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!);
@@ -270,7 +262,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body); const {email} = CampaignSchemas.sendTest.parse(req.body);
+4
View File
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
import { import {
API_URI, API_URI,
AWS_SES_REGION,
DASHBOARD_URI, DASHBOARD_URI,
GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED,
@@ -59,6 +60,9 @@ export class Config {
: null, : null,
}, },
}, },
aws: {
sesRegion: AWS_SES_REGION,
},
}); });
} }
} }
+13 -16
View File
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
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 signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -16,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 {
@@ -35,7 +32,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -54,7 +51,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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!);
@@ -80,7 +77,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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);
@@ -113,7 +110,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -133,7 +130,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -163,7 +160,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -184,7 +181,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -284,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'});
@@ -349,7 +346,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -376,7 +373,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -402,7 +399,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {contactIds} = req.body; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -437,7 +434,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) { public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {contactIds} = req.body; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -471,7 +468,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) { public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
const {contactIds} = req.body; const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) { if (!Array.isArray(contactIds) || contactIds.length === 0) {
+9 -52
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, requireEmailVerified} 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')
@@ -20,20 +19,11 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @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);
@@ -47,7 +37,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @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);
@@ -107,7 +85,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @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);
@@ -144,7 +113,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified]) @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);
+7 -9
View File
@@ -1,8 +1,6 @@
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 signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -17,7 +15,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -37,7 +35,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -54,7 +52,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -71,7 +69,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -92,7 +90,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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!);
@@ -108,7 +106,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -135,7 +133,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified]) @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) {
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import { import {
API_URI, API_URI,
DASHBOARD_URI, DASHBOARD_URI,
DISABLE_SIGNUPS,
GITHUB_OAUTH_CLIENT, GITHUB_OAUTH_CLIENT,
GITHUB_OAUTH_ENABLED, GITHUB_OAUTH_ENABLED,
GITHUB_OAUTH_SECRET, GITHUB_OAUTH_SECRET,
} from '../../app/constants.js'; } from '../../app/constants.js';
import {prisma} from '../../database/prisma.js'; import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js'; import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js'; import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js'; import {UserService} from '../../services/UserService.js';
@@ -81,6 +83,11 @@ export class Github {
let isNewUser = false; let isNewUser = false;
if (!user) { if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
email, email,
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import { import {
API_URI, API_URI,
DASHBOARD_URI, DASHBOARD_URI,
DISABLE_SIGNUPS,
GOOGLE_OAUTH_CLIENT, GOOGLE_OAUTH_CLIENT,
GOOGLE_OAUTH_ENABLED, GOOGLE_OAUTH_ENABLED,
GOOGLE_OAUTH_SECRET, GOOGLE_OAUTH_SECRET,
} from '../../app/constants.js'; } from '../../app/constants.js';
import {prisma} from '../../database/prisma.js'; import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js'; import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js'; import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js'; import {UserService} from '../../services/UserService.js';
@@ -71,6 +73,11 @@ export class Google {
let isNewUser = false; let isNewUser = false;
if (!user) { if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({ user = await prisma.user.create({
data: { data: {
email, email,
+21 -173
View File
@@ -4,8 +4,8 @@ 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, requireEmailVerified} from '../middleware/auth.js';
import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js'; import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,20 +19,11 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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({
@@ -92,20 +83,11 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @Middleware([requireAuth, requireEmailVerified])
@CatchAsync @CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) { private async getSecurityMetrics(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);
// 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');
}
// Use existing SecurityService // Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id); const metrics = await SecurityService.getProjectSecurityMetrics(id);
@@ -124,43 +106,18 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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,
})),
}); });
} }
@@ -173,7 +130,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
// Validate params // Validate params
@@ -190,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({
@@ -214,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,
@@ -256,7 +181,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @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
@@ -276,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({
@@ -319,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,
@@ -348,7 +234,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified]) @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
@@ -360,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,
+8 -10
View File
@@ -1,7 +1,5 @@
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 type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -16,7 +14,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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!);
@@ -31,7 +29,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -51,7 +49,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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);
@@ -73,7 +71,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -102,7 +100,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -132,7 +130,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -152,7 +150,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -172,7 +170,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified]) @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) {
+7 -9
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 {TemplateType} from '@plunk/db'; import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -18,7 +16,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -37,7 +35,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -57,7 +55,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -101,7 +99,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -136,7 +134,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -156,7 +154,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -176,7 +174,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified]) @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) {
+1 -4
View File
@@ -2,8 +2,6 @@ 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 signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js'; import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -15,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)) {
@@ -36,7 +33,7 @@ export class Uploads {
@Middleware([requireAuth, requireEmailVerified, 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()) {
+29 -151
View File
@@ -8,9 +8,9 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON
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 {ErrorCode, HttpException, 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, requireEmailVerified} 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';
@@ -23,7 +23,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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();
@@ -42,7 +42,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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();
@@ -57,7 +57,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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();
@@ -104,24 +104,12 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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({
@@ -136,23 +124,11 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(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')}`;
@@ -188,7 +164,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query; const {currency} = req.query;
@@ -197,20 +173,8 @@ export class Users {
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({
@@ -280,7 +244,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -288,20 +252,8 @@ export class Users {
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({
@@ -330,7 +282,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -342,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);
@@ -363,7 +306,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -377,19 +320,7 @@ 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) {
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
// Get the project with current limits // Get the project with current limits
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
@@ -442,7 +373,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -459,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},
@@ -568,7 +490,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled // Check if billing is enabled
@@ -585,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({
@@ -656,7 +569,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -668,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);
@@ -689,7 +593,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -701,19 +605,7 @@ 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) {
throw new NotFound('Project not found or you do not have permission to reset it');
}
// Check if project is disabled - block reset operation // Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id); const isDisabled = await SecurityService.isProjectDisabled(id);
@@ -775,7 +667,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified]) @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} = UtilitySchemas.id.parse(req.params); const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) { if (!auth.userId) {
@@ -787,21 +679,7 @@ 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) {
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 and disabled status // Get project to check for active subscription and disabled status
const project = await prisma.project.findUnique({ const project = await prisma.project.findUnique({
+158 -13
View File
@@ -8,6 +8,7 @@ import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js'; import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js'; import {EventService} from '../services/EventService.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';
@@ -21,7 +22,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
export class Webhooks { export class Webhooks {
/** /**
* Receive SNS webhook notifications from AWS SES * Receive SNS webhook notifications from AWS SES
* Handles email events: delivery, open, click, bounce, complaint * Handles outbound email events: delivery, open, click, bounce, complaint
* Handles inbound email notifications: received emails via SES receiving
*/ */
@Post('sns') @Post('sns')
@CatchAsync @CatchAsync
@@ -67,6 +69,114 @@ export class Webhooks {
// Parse the nested SES event from the Message field // Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message); const body = JSON.parse(req.body.Message);
// Check if this is an inbound email notification (SES Receiving)
if (body.notificationType === 'Received') {
signale.info('[WEBHOOK] Received inbound email notification from SES');
try {
// Extract recipient addresses from the inbound email
const recipients = body.receipt?.recipients || [];
if (recipients.length === 0) {
signale.warn('[WEBHOOK] No recipients found in inbound email');
return res.status(200).json({success: true, message: 'No recipients found'});
}
// For each recipient, identify the domain and create events
for (const recipient of recipients) {
const recipientEmail = recipient as string;
const domain = recipientEmail.split('@')[1];
if (!domain) {
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
continue;
}
// Find ALL projects that have this domain verified
// A domain can be shared across multiple projects if users are members of both
const domainRecords = await prisma.domain.findMany({
where: {
domain,
verified: true, // Only process emails for verified domains
},
include: {
project: true,
},
});
if (domainRecords.length === 0) {
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
continue;
}
signale.info(
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
);
// Extract sender information (same for all projects)
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
// Find or create a contact for the sender in this project
let contact;
if (senderEmail) {
contact = await ContactService.upsert(
domainRecord.projectId,
senderEmail,
undefined, // No additional data
true, // Subscribe by default for inbound email senders
);
}
// Prepare event data with all inbound email details
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
fromHeader: senderFromHeader,
to: recipientEmail,
subject: body.mail?.commonHeaders?.subject,
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
spfVerdict: body.receipt?.spfVerdict?.status,
dkimVerdict: body.receipt?.dkimVerdict?.status,
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
// Processing metadata
processingTimeMillis: body.receipt?.processingTimeMillis,
};
// Create the email.received event (this will trigger workflows)
await EventService.trackEvent(
domainRecord.projectId,
'email.received',
contact?.id,
undefined, // No emailId for inbound emails (they're not sent by us)
eventData,
);
signale.success(
`[WEBHOOK] Created email.received event for ${senderEmail}${recipientEmail} (project: ${domainRecord.project.name})`,
);
}
}
return res.status(200).json({success: true, message: 'Inbound email processed'});
} catch (inboundError) {
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
// Return 200 to acknowledge receipt even if processing failed
return res.status(200).json({success: true, message: 'Error processing inbound email'});
}
}
// Handle outbound email event notifications (existing logic)
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click'; const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId; const messageId = body.mail?.messageId;
@@ -152,29 +262,62 @@ export class Webhooks {
break; break;
} }
case 'Bounce': case 'Bounce': {
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`); const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED; updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now; updateData.bouncedAt = now;
// Unsubscribe contact on bounce // Unsubscribe contact on permanent bounce
await prisma.contact.update({ await prisma.contact.update({
where: {id: email.contactId}, where: {id: email.contactId},
data: {subscribed: false}, data: {subscribed: false},
}); });
eventData = { eventData = {
...baseEventData, ...baseEventData,
bounceType: body.bounce?.bounceType, bounceType,
bouncedAt: now.toISOString(), bouncedAt: now.toISOString(),
}; };
// Send notification about bounce // Send notification about permanent bounce
await NtfyService.notifyEmailBounce( await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
email.project.name, } else if (isTransientBounce) {
email.projectId, // Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
email.contact.email, signale.info(
body.bounce?.bounceType, `[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
); );
// Don't update email status or unsubscribe contact
// Just track the event for visibility
eventData = {
...baseEventData,
bounceType,
transientBounce: true,
};
} else {
// Unknown bounce type - treat as permanent to be safe
signale.warn(
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
}
break; break;
}
case 'Complaint': case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`); signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
@@ -208,8 +351,10 @@ export class Webhooks {
// Track event (this will trigger workflows) // Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData); await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events // Check security limits only for permanent bounces and complaints
if (eventType === 'Bounce' || eventType === 'Complaint') { // Transient bounces (soft bounces) don't count toward bounce rate
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
if (isPermanentBounce || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId); await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
} }
+16 -18
View File
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
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 signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js'; import {requireAuth, requireEmailVerified} 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';
@@ -18,7 +16,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -38,7 +36,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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 {
@@ -61,7 +59,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -81,7 +79,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -111,7 +109,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -139,7 +137,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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) {
@@ -159,7 +157,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -191,7 +189,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -218,7 +216,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -239,7 +237,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -269,7 +267,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -290,7 +288,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -315,7 +313,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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);
@@ -338,7 +336,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -359,7 +357,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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;
@@ -380,7 +378,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified]) @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) {
+1 -3
View File
@@ -74,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,
@@ -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
+2 -1
View File
@@ -3,11 +3,12 @@
* Processes bulk subscribe, unsubscribe, and delete operations * Processes bulk subscribe, unsubscribe, and delete operations
*/ */
import type {BulkContactActionJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq'; import {type Job, Worker} from 'bullmq';
import signale from 'signale'; import signale from 'signale';
import {ContactService} from '../services/ContactService.js'; import {ContactService} from '../services/ContactService.js';
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js'; import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100 const BATCH_SIZE = 100; // Process contacts in batches of 100
+2 -1
View File
@@ -3,11 +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 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>(
@@ -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';
+81 -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,34 @@ 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 {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
const ttl = 604800; // 7 days
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(dbDomain.projectId);
const 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)),
);
}
}
} 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 +157,38 @@ 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 {
// Use SETNX to atomically check and set the flag (prevents race conditions)
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 endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
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)),
);
}
}
} 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));
} }
+22 -4
View File
@@ -4,16 +4,17 @@
*/ */
import {CampaignStatus, 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 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 {getSendingQuota, sendRawEmail} from '../services/SESService.js'; import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
/** /**
* Determine the email sending rate limit (emails per second) * Determine the email sending rate limit (emails per second)
@@ -116,10 +117,25 @@ export async function createEmailWorker() {
const fromName = email.fromName || email.project.name; const fromName = email.fromName || email.project.name;
const fromEmail = email.from; const fromEmail = email.from;
// Parse custom headers from JSON
const customHeaders =
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
? (email.headers as Record<string, string>)
: undefined;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Build recipient with name if available // Build recipient with name if available
const recipient: {name?: string; email: string} | string = email.toName const recipient: {name?: string; email: string} | string = email.toName
? {name: email.toName, email: email.contact.email} ? {name: email.toName, email: recipientEmail}
: email.contact.email; : recipientEmail;
// Determine tracking based on project settings and email type // Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType); const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
@@ -136,6 +152,7 @@ export async function createEmailWorker() {
html: compiledHtml, html: compiledHtml,
}, },
reply: email.replyTo || undefined, reply: email.replyTo || undefined,
headers: publicHeaders,
tracking: shouldTrack, tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null, attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
}); });
@@ -203,6 +220,7 @@ export async function createEmailWorker() {
where: {id: email.campaignId}, where: {id: email.campaignId},
data: { data: {
status: CampaignStatus.SENT, status: CampaignStatus.SENT,
sentCount,
}, },
}); });
+2 -1
View File
@@ -3,6 +3,7 @@
* 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 signale from 'signale';
@@ -10,7 +11,7 @@ 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
+2 -1
View File
@@ -4,12 +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 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>(
+23 -37
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';
/** /**
@@ -17,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
* - For segments without trackMembership: only update counts * - For segments without trackMembership: only update counts
*/ */
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> { async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
// Get all segments for this project, separating tracked vs non-tracked // Get all segments for this project, separating tracked vs non-tracked
const segments = await prisma.segment.findMany({ const segments = await prisma.segment.findMany({
where: {projectId}, where: {projectId},
@@ -28,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
const trackedSegments = segments.filter(s => s.trackMembership); const trackedSegments = segments.filter(s => s.trackMembership);
const nonTrackedSegments = segments.filter(s => !s.trackMembership); const nonTrackedSegments = segments.filter(s => !s.trackMembership);
signale.info(
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
);
// Process tracked segments with full membership computation (creates events) // Process tracked segments with full membership computation (creates events)
let updatedSegmentCount = 0;
let totalAdded = 0;
let totalRemoved = 0;
if (trackedSegments.length > 0) { if (trackedSegments.length > 0) {
for (const segment of trackedSegments) { for (const segment of trackedSegments) {
try { try {
signale.info(
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
);
const result = await SegmentService.computeMembership(projectId, segment.id); const result = await SegmentService.computeMembership(projectId, segment.id);
signale.success(
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
);
// Notify about segment membership update only if there were actual changes // Track segments with actual changes for bundled notification
if (projectName && (result.added > 0 || result.removed > 0)) { if (result.added > 0 || result.removed > 0) {
await NtfyService.notifySegmentMembershipComputed( updatedSegmentCount++;
segment.name, totalAdded += result.added;
projectName, totalRemoved += result.removed;
projectId,
result.total,
result.added,
result.removed,
);
} }
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
// Continue with other segments // Continue with other segments
} }
} }
// Send bundled notification if there were any changes
if (projectName && updatedSegmentCount > 0) {
await NtfyService.notifySegmentMembershipBundled(
projectName,
projectId,
updatedSegmentCount,
totalAdded,
totalRemoved,
);
}
} }
// Process non-tracked segments with count-only update (lightweight) // Process non-tracked segments with count-only update (lightweight)
if (nonTrackedSegments.length > 0) { if (nonTrackedSegments.length > 0) {
try { try {
await SegmentService.refreshAllMemberCounts(projectId); await SegmentService.refreshAllMemberCounts(projectId);
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
} }
@@ -79,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> { async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
const {projectId} = job.data; const {projectId} = job.data;
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
try { try {
if (projectId) { if (projectId) {
// Process specific project // Process specific project
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
await processProjectSegments(projectId); await processProjectSegments(projectId);
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
} else { } else {
// Process all active projects // Process all active projects
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
@@ -94,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
select: {id: true, name: true}, select: {id: true, name: true},
}); });
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`); signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database // Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10; const PROJECT_BATCH_SIZE = 10;
@@ -104,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
await Promise.all( await Promise.all(
batch.map(async project => { batch.map(async project => {
try { try {
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
await processProjectSegments(project.id, project.name); await processProjectSegments(project.id, project.name);
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// Don't throw - continue with other projects // Don't throw - continue with other projects
@@ -119,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
await new Promise(resolve => setTimeout(resolve, 2000)); await new Promise(resolve => setTimeout(resolve, 2000));
} }
} }
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
} }
} catch (error) { } catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
@@ -147,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
}, },
); );
worker.on('completed', job => {
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => { worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error); signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
}); });
@@ -3,10 +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 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() {
@@ -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) {
+28 -109
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, PLUNK_ENABLED} 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
@@ -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);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'jwt',
userId,
projectId,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
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,
);
}
}
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(
@@ -193,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = { res.locals.auth = {
type: 'apiKey', type: 'apiKey',
projectId: project.id, 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) {
@@ -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(
@@ -262,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = { res.locals.auth = {
type: 'apiKey', type: 'apiKey',
projectId: project.id, 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) {
@@ -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(
@@ -360,18 +292,8 @@ 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) {
@@ -383,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
type: 'jwt', type: 'jwt',
userId, userId,
projectId, projectId,
} as AuthResponse; };
// Check if project is disabled - block write operations // Check if project is disabled - block write operations
if (project?.disabled) { if (project?.disabled) {
@@ -414,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
*/ */
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => { export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try { try {
const auth = res.locals.auth as AuthResponse; const auth = res.locals.auth;
if (auth.type === 'apiKey') { if (auth.type === 'apiKey') {
return next(); return next();
@@ -424,10 +346,7 @@ export const requireEmailVerified = async (req: Request, res: Response, next: Ne
throw new NotAuthenticated(); throw new NotAuthenticated();
} }
const user = await prisma.user.findUnique({ const user = await UserService.id(auth.userId);
where: {id: auth.userId},
select: {emailVerified: true, type: true},
});
if (!user) { if (!user) {
throw new NotAuthenticated(); throw new NotAuthenticated();
+83 -65
View File
@@ -1,61 +1,12 @@
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 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 {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
* *
@@ -107,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);
@@ -158,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,
}; };
} }
@@ -415,15 +366,38 @@ export class ActivityService {
...(contactId ? {contactId} : {}), ...(contactId ? {contactId} : {}),
}; };
// Build OR conditions to filter by the appropriate timestamp field for each activity type
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
const orConditions: Prisma.EmailWhereInput[] = [];
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
orConditions.push({sentAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
orConditions.push({openedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
orConditions.push({clickedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
orConditions.push({complainedAt: {not: null, ...dateFilter}});
}
// If no OR conditions, return empty (shouldn't happen but defensive)
if (orConditions.length === 0) {
return [];
}
const emails = await prisma.email.findMany({ const emails = await prisma.email.findMany({
where: { where: {
...where, ...where,
createdAt: cursorTimestamp OR: orConditions,
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
}, },
orderBy: {createdAt: 'desc'}, orderBy: {createdAt: 'desc'},
take: limit, take: limit,
@@ -450,6 +424,27 @@ export class ActivityService {
}, },
}); });
// Helper function to check if timestamp is within date range
const isInDateRange = (timestamp: Date | null) => {
if (!timestamp) return false;
// Check against date filter
const time = timestamp.getTime();
if (dateFilter.gte) {
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
if (time < gteTime) return false;
}
if (dateFilter.lte) {
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
if (time > lteTime) return false;
}
// Check against cursor for pagination
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
return true;
};
// Convert each email into multiple activities based on its state // Convert each email into multiple activities based on its state
for (const email of emails) { for (const email of emails) {
const baseMetadata = { const baseMetadata = {
@@ -464,7 +459,7 @@ export class ActivityService {
workflowName: email.workflowExecution?.workflow?.name, workflowName: email.workflowExecution?.workflow?.name,
}; };
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) { if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
activities.push({ activities.push({
id: `${email.id}_sent`, id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT, type: ActivityType.EMAIL_SENT,
@@ -475,7 +470,11 @@ export class ActivityService {
}); });
} }
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) { if (
email.deliveredAt &&
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
isInDateRange(email.deliveredAt)
) {
activities.push({ activities.push({
id: `${email.id}_delivered`, id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED, type: ActivityType.EMAIL_DELIVERED,
@@ -486,7 +485,7 @@ export class ActivityService {
}); });
} }
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) { if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
activities.push({ activities.push({
id: `${email.id}_opened`, id: `${email.id}_opened`,
type: ActivityType.EMAIL_OPENED, type: ActivityType.EMAIL_OPENED,
@@ -501,7 +500,7 @@ export class ActivityService {
} }
// Email clicked // Email clicked
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) { if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
activities.push({ activities.push({
id: `${email.id}_clicked`, id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED, type: ActivityType.EMAIL_CLICKED,
@@ -516,7 +515,7 @@ export class ActivityService {
} }
// Email bounced // Email bounced
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) { if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
activities.push({ activities.push({
id: `${email.id}_bounced`, id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED, type: ActivityType.EMAIL_BOUNCED,
@@ -529,6 +528,25 @@ export class ActivityService {
}, },
}); });
} }
// Email complaint
if (
email.complainedAt &&
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
isInDateRange(email.complainedAt)
) {
activities.push({
id: `${email.id}_complaint`,
type: ActivityType.EMAIL_COMPLAINT,
timestamp: email.complainedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
} }
return activities; return activities;
+16 -5
View File
@@ -58,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 = Keys.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;
@@ -191,7 +193,11 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // Check cache
const cacheKey = Keys.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);
@@ -295,7 +301,12 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now; const effectiveEndDate = endDate || now;
// Check cache // Check cache
const cacheKey = Keys.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);
+24 -57
View File
@@ -1,4 +1,5 @@
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 {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react'; import React from 'react';
import signale from 'signale'; import signale from 'signale';
@@ -8,41 +9,9 @@ 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 {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
@@ -611,16 +580,20 @@ export class BillingLimitService {
const month = String(now.getMonth() + 1).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month); const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey); // Use SETNX (SET if Not eXists) to atomically check and set the flag
if (alreadySent === '1') { // This prevents race conditions where multiple concurrent requests could all pass the check
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
// Email was already sent this month
return; return;
} }
const members = await prisma.membership.findMany({ const members = await MembershipService.getMembers(projectId);
where: {projectId}, const emails = members.map(m => m.email);
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) { if (emails.length === 0) {
return; return;
} }
@@ -637,11 +610,6 @@ export class BillingLimitService {
}); });
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template))); await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
// Mark that we've sent the warning email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) { } catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error); signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
} }
@@ -664,16 +632,20 @@ export class BillingLimitService {
const month = String(now.getMonth() + 1).padStart(2, '0'); const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month); const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey); // Use SETNX (SET if Not eXists) to atomically check and set the flag
if (alreadySent === '1') { // This prevents race conditions where multiple concurrent requests could all pass the check
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
// Email was already sent this month
return; return;
} }
const members = await prisma.membership.findMany({ const members = await MembershipService.getMembers(projectId);
where: {projectId}, const emails = members.map(m => m.email);
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) { if (emails.length === 0) {
return; return;
} }
@@ -689,11 +661,6 @@ export class BillingLimitService {
}); });
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template))); await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
// Mark that we've sent the limit email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) { } catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error); signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
} }
+10 -33
View File
@@ -1,6 +1,7 @@
import type {Campaign, Contact, Prisma} from '@plunk/db'; import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} 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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -18,32 +19,6 @@ 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 | null;
replyTo?: string | null;
audienceType: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
}
export interface UpdateCampaignData {
name?: string;
description?: string;
subject?: string;
body?: string;
from?: string;
fromName?: string | null;
replyTo?: string | null;
audienceType?: CampaignAudienceType;
audienceCondition?: FilterCondition;
segmentId?: string;
}
export class CampaignService { export class CampaignService {
/** /**
* Create a new campaign * Create a new campaign
@@ -85,7 +60,7 @@ 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 totalRecipients: 0, // Will be updated below
@@ -133,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) {
@@ -209,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;
@@ -232,7 +207,7 @@ export class CampaignService {
]); ]);
return { return {
campaigns, data: campaigns,
total, total,
page, page,
pageSize, pageSize,
@@ -476,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,
@@ -589,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,
@@ -747,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');
} }
@@ -783,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 {
+64 -74
View File
@@ -1,18 +1,12 @@
import {type Contact, Prisma} from '@plunk/db'; import {type Contact, Prisma} from '@plunk/db';
import {isValidLanguageCode} from '@plunk/shared'; import {isValidLanguageCode} from '@plunk/shared';
import type {FilterCondition, FilterGroup} from '@plunk/types'; 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
@@ -23,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
@@ -58,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,
@@ -227,7 +221,15 @@ export class ContactService {
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
// Skip reserved system-generated fields // Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden // These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl']; const reservedFields = [
'plunk_id',
'plunk_email',
'id',
'email',
'unsubscribeUrl',
'subscribeUrl',
'manageUrl',
];
if (reservedFields.includes(key)) { if (reservedFields.includes(key)) {
continue; continue;
} }
@@ -236,10 +238,7 @@ export class ContactService {
if (key === 'locale') { if (key === 'locale') {
if (typeof value === 'string') { if (typeof value === 'string') {
if (!isValidLanguageCode(value)) { if (!isValidLanguageCode(value)) {
throw new HttpException( throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
400,
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
);
} }
} else if (value !== null && value !== undefined) { } else if (value !== null && value !== undefined) {
throw new HttpException(400, 'Locale must be a string'); throw new HttpException(400, 'Locale must be a string');
@@ -272,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} : {}),
}, },
}); });
@@ -292,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,
}, },
}); });
@@ -678,59 +677,11 @@ export class ContactService {
return {deletedFrom: result}; return {deletedFrom: result};
} }
/**
* Helper: Check if a field is used in a filter condition (recursive)
*/
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
if (!condition || typeof condition !== 'object') {
return false;
}
// Check groups in the condition
if (Array.isArray(condition.groups)) {
for (const group of condition.groups) {
if (this.fieldUsedInGroup(field, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if a field is used in a filter group (recursive)
*/
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
if (!group || typeof group !== 'object') {
return false;
}
// Check filters in the group
if (Array.isArray(group.filters)) {
for (const filter of group.filters) {
if (filter.field === field) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.fieldUsedInCondition(field, group.conditions);
}
return false;
}
/** /**
* Bulk subscribe contacts * Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches * Updates multiple contacts to subscribed=true in batches
*/ */
public static async bulkSubscribe( public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
// Verify all contacts belong to this project // Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: { where: {
@@ -779,10 +730,7 @@ export class ContactService {
/** /**
* Bulk unsubscribe contacts * Bulk unsubscribe contacts
*/ */
public static async bulkUnsubscribe( public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({ const contacts = await prisma.contact.findMany({
where: { where: {
id: {in: contactIds}, id: {in: contactIds},
@@ -829,10 +777,7 @@ export class ContactService {
/** /**
* Bulk delete contacts * Bulk delete contacts
*/ */
public static async bulkDelete( public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
projectId: string,
contactIds: string[],
): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({ const result = await prisma.contact.deleteMany({
where: { where: {
id: {in: contactIds}, id: {in: contactIds},
@@ -843,6 +788,51 @@ export class ContactService {
return {deleted: result.count}; return {deleted: result.count};
} }
/**
* Helper: Check if a field is used in a filter condition (recursive)
*/
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
if (!condition || typeof condition !== 'object') {
return false;
}
// Check groups in the condition
if (Array.isArray(condition.groups)) {
for (const group of condition.groups) {
if (this.fieldUsedInGroup(field, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if a field is used in a filter group (recursive)
*/
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
if (!group || typeof group !== 'object') {
return false;
}
// Check filters in the group
if (Array.isArray(group.filters)) {
for (const filter of group.filters) {
if (filter.field === field) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.fieldUsedInCondition(field, group.conditions);
}
return false;
}
/** /**
* Track events sequentially to avoid database deadlocks * Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling * Processes events one at a time with error handling
+90 -3
View File
@@ -1,9 +1,14 @@
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 {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
export class DomainService { export class DomainService {
/** /**
@@ -81,6 +86,32 @@ 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 {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(domainId);
const ttl = 604800; // 7 days
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const 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)));
}
}
} 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 +124,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 {
// Use SETNX to atomically check and set the flag (prevents race conditions)
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 endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
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)));
}
}
} catch (emailError) {
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
}
} }
return { return {
@@ -184,6 +249,28 @@ export class DomainService {
await prisma.domain.delete({where: {id: domainId}}); await prisma.domain.delete({where: {id: domainId}});
// Check if this domain is still attached to another project
const domainExistsElsewhere = await prisma.domain.findFirst({
where: {
domain: domainName,
},
});
// If domain is not used by any other project, remove it from AWS SES
if (!domainExistsElsewhere) {
try {
await deleteIdentity(domainName);
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
} catch (error) {
// Log error but don't fail the domain removal if AWS cleanup fails
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
}
} else {
signale.info(
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
);
}
// Send notification about domain removal // Send notification about domain removal
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id); await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
+68 -13
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, createTranslatorSync} 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';
@@ -34,6 +35,7 @@ interface SendEmailParams {
campaignId?: string; campaignId?: string;
workflowExecutionId?: string; workflowExecutionId?: string;
workflowStepExecutionId?: string; workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
} }
/** /**
@@ -91,8 +93,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 +154,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,
@@ -192,7 +194,8 @@ export class EmailService {
// Check subscription status for marketing emails // Check subscription status for marketing emails
// Transactional emails should always be sent regardless of subscription status // Transactional emails should always be sent regardless of subscription status
if (sourceType !== EmailSourceType.TRANSACTIONAL) { // Custom recipient emails also bypass subscription checks (they're not in the contact list)
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
const contact = await prisma.contact.findUnique({ const contact = await prisma.contact.findUnique({
where: {id: params.contactId}, where: {id: params.contactId},
select: {subscribed: true}, select: {subscribed: true},
@@ -213,8 +216,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,
@@ -241,6 +244,12 @@ export class EmailService {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`); signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
} }
// If custom recipient email is provided, store it in headers for later use
const emailHeaders = params.headers ? {...params.headers} : {};
if (params.recipientEmail) {
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
}
const email = await prisma.email.create({ const email = await prisma.email.create({
data: { data: {
projectId: params.projectId, projectId: params.projectId,
@@ -250,8 +259,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: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : 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 +341,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,
@@ -359,6 +369,15 @@ export class EmailService {
? (email.headers as Record<string, string>) ? (email.headers as Record<string, string>)
: undefined; : undefined;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Parse attachments from JSON // Parse attachments from JSON
const attachments = const attachments =
email.attachments && Array.isArray(email.attachments) email.attachments && Array.isArray(email.attachments)
@@ -374,13 +393,13 @@ export class EmailService {
name: fromName, name: fromName,
email: fromEmail, email: fromEmail,
}, },
to: [email.contact.email], to: [recipientEmail],
content: { content: {
subject: formattedEmail.subject, subject: formattedEmail.subject,
html: compiledHtml, html: compiledHtml,
}, },
reply: email.replyTo || undefined, reply: email.replyTo || undefined,
headers: customHeaders, headers: publicHeaders,
attachments: attachments, attachments: attachments,
tracking: shouldTrack, tracking: shouldTrack,
}); });
@@ -502,6 +521,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 +564,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,
}, },
}); });
} }
+188 -83
View File
@@ -1,26 +1,154 @@
import {promises as dns} from 'dns'; import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker'; import {run} from '@zootools/email-spell-checker';
import type {EmailVerificationResult} from '@plunk/types';
import {redis} from '../database/redis.js'; import {redis} from '../database/redis.js';
export interface EmailVerificationResult {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
const DISPOSABLE_DOMAINS_URL = const DISPOSABLE_DOMAINS_URL =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf'; 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains'; const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
const PERSONAL_DOMAINS_URL =
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily) const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
// Known email forwarding/alias services
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 { export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null; private static disposableDomainsSet: Set<string> | null = null;
private static personalDomainsSet: Set<string> | null = null;
/**
* Verify an email address
* - Checks for NS records (proves domain exists in DNS)
* - Checks for MX records (required for receiving email)
* - Checks for A/AAAA records (informational - indicates if domain has a website)
* - Detects disposable email addresses
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
* - 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,
isPersonalEmail: false,
domainExists: false,
hasWebsite: 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 personal/free email provider
result.isPersonalEmail = await this.isPersonalEmailDomain(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;
}
// Step 1: Check NS records - proves the domain exists in DNS
try {
const nsRecords = await dns.resolveNs(domain);
result.domainExists = nsRecords && nsRecords.length > 0;
} catch {
result.domainExists = false;
result.valid = false;
result.reasons.push('Domain does not exist (no nameservers found)');
// If domain doesn't exist, no point checking MX/A records
return result;
}
// Step 2: Check MX records - required for receiving email
try {
const mxRecords = await dns.resolveMx(domain);
result.hasMxRecords = mxRecords && mxRecords.length > 0;
if (!result.hasMxRecords) {
result.valid = false;
result.reasons.push('Domain cannot receive email (no MX records found)');
}
} catch {
result.hasMxRecords = false;
result.valid = false;
result.reasons.push('Domain cannot receive email (no MX records found)');
}
// Step 3: Check if domain has A/AAAA records - informational only
// This indicates if the domain has a website/web server
// Doesn't affect email validity since email only requires MX records
try {
await dns.resolve(domain, 'A');
result.hasWebsite = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.hasWebsite = true;
} catch {
// Domain doesn't have A/AAAA records (no website), but this is OK for email
result.hasWebsite = false;
}
}
// 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 * Fetch and cache the disposable domains list from GitHub
@@ -76,85 +204,62 @@ export class EmailVerificationService {
} }
/** /**
* Verify an email address * Check if a domain is a known forwarding/alias service
* - Checks if domain exists (DNS A/AAAA records)
* - Checks for MX records
* - Detects disposable email addresses
* - Suggests corrections for common typos
*/ */
static async verifyEmail(email: string): Promise<EmailVerificationResult> { private static isForwardingDomain(domain: string): boolean {
const result: EmailVerificationResult = { return FORWARDING_DOMAINS.has(domain.toLowerCase());
email,
valid: true,
isDisposable: false,
isTypo: false,
isPlusAddressed: false,
domainExists: false,
hasMxRecords: false,
reasons: [],
};
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
result.valid = false;
result.reasons.push('Invalid email format');
return result;
} }
const domain = emailParts[1]!; // Safe to assert, we already validated length /**
* Fetch and cache the personal email domains list from GitHub
// Check if email is from a disposable domain using GitHub list * Uses Redis for caching with 24-hour TTL
result.isDisposable = await this.isDisposableDomain(domain); * Falls back to in-memory cache if Redis fails
*/
// Check for plus addressing private static async getPersonalEmailDomains(): Promise<Set<string>> {
result.isPlusAddressed = emailParts[0]!.includes('+'); // Return in-memory cache if available
if (this.personalDomainsSet) {
// Check for common typos and suggest corrections return this.personalDomainsSet;
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 { try {
await dns.resolve(domain, 'A'); // Try to get from Redis cache first
result.domainExists = true; const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
} catch { if (cached) {
// Try AAAA records if A records fail const domains = JSON.parse(cached) as string[];
try { this.personalDomainsSet = new Set(domains);
await dns.resolve(domain, 'AAAA'); return this.personalDomainsSet;
result.domainExists = true; }
} catch {
result.domainExists = false; // Fetch from GitHub if not in cache
result.valid = false; const response = await fetch(PERSONAL_DOMAINS_URL);
result.reasons.push('Domain does not exist'); if (!response.ok) {
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.personalDomainsSet = new Set(domains);
return this.personalDomainsSet;
} catch (error) {
console.error('Error fetching personal email domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
} }
} }
// Check MX records (only if domain exists) /**
if (result.domainExists) { * Check if a domain is a personal/free email provider
try { */
const mxRecords = await dns.resolveMx(domain); private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
result.hasMxRecords = mxRecords && mxRecords.length > 0; const personalDomains = await this.getPersonalEmailDomains();
if (!result.hasMxRecords) { return personalDomains.has(domain.toLowerCase());
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;
} }
} }
+3 -2
View File
@@ -1,6 +1,7 @@
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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -32,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,
}, },
}); });
@@ -472,7 +473,7 @@ 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,
}, },
}); });
+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);
}
}
}
+76 -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,6 +207,18 @@ 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 ttl = 3600; // 1 hour
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
const warningText = warnings.join(', '); const warningText = warnings.join(', ');
await this.sendDefault( await this.sendDefault(
'Security Warning', 'Security Warning',
@@ -352,6 +330,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,6 +601,18 @@ 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 ttl = 86400; // 24 hours
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
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})`,
@@ -629,6 +632,18 @@ 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 ttl = 86400; // 24 hours
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
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.`,
@@ -747,6 +762,32 @@ export class NtfyService {
}); });
} }
/**
* Notify about bundled segment membership updates - LOW priority
* Used when multiple segments are updated in a single processing cycle
*/
public static async notifySegmentMembershipBundled(
projectName: string,
projectId: string,
segmentCount: number,
totalAdded: number,
totalRemoved: number,
): Promise<void> {
const changes: string[] = [];
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
await this.send({
title: 'Segment Memberships Updated',
message,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/** /**
* Notify about segment deleted - MIN priority * Notify about segment deleted - MIN priority
*/ */
+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,
},
});
});
}
}
+11 -53
View File
@@ -1,63 +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
}
export interface BulkContactActionJobData {
projectId: string;
contactIds: string[];
operation: 'subscribe' | 'unsubscribe' | 'delete';
}
/** /**
* Queue Configuration * Queue Configuration
*/ */
+5 -6
View File
@@ -1,21 +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 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';
/** /**
@@ -46,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,
+8 -1
View File
@@ -8,7 +8,7 @@ import {
DASHBOARD_URI, DASHBOARD_URI,
SES_CONFIGURATION_SET, SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING, SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED TRACKING_TOGGLE_ENABLED,
} from '../app/constants.js'; } from '../app/constants.js';
/** /**
@@ -258,6 +258,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
}); });
}; };
/**
* Delete a verified domain identity from AWS SES
*/
export const deleteIdentity = async (domain: string): Promise<void> => {
await ses.deleteIdentity({Identity: domain});
};
/** /**
* Get AWS SES account sending quota and rate limit * Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails * @returns MaxSendRate (emails per second) or null if the call fails
+85 -67
View File
@@ -5,6 +5,7 @@ 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 {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'; import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
@@ -18,16 +19,23 @@ 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.075,
COMPLAINT_7DAY_CRITICAL: 0.1, COMPLAINT_7DAY_CRITICAL: 0.15,
COMPLAINT_ALLTIME_WARNING: 0.02, COMPLAINT_ALLTIME_WARNING: 0.03,
COMPLAINT_ALLTIME_CRITICAL: 0.08, COMPLAINT_ALLTIME_CRITICAL: 0.12,
// Minimum absolute counts (prevents small sample size false positives)
// Both percentage AND absolute count must be exceeded to trigger
MIN_BOUNCES_FOR_CRITICAL: 10,
MIN_BOUNCES_FOR_WARNING: 5,
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
} as const; } as const;
interface RateData { interface RateData {
@@ -177,26 +185,7 @@ export class SecurityService {
hasDisabledProject: boolean; hasDisabledProject: boolean;
disabledProjectNames: string[]; disabledProjectNames: string[];
}> { }> {
const disabledMemberships = await prisma.membership.findMany({ return MembershipService.userHasDisabledProject(userId);
where: {
userId,
project: {
disabled: true,
},
},
include: {
project: {
select: {
name: true,
},
},
},
});
return {
hasDisabledProject: disabledMemberships.length > 0,
disabledProjectNames: disabledMemberships.map(m => m.project.name),
};
} }
/** /**
@@ -293,50 +282,82 @@ 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) { // Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push( violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`, `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
); );
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) { } else if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push( warnings.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`, `7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
);
}
}
// Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
);
} else if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
);
}
}
// Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate - requires BOTH rate AND absolute count
if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
);
} else if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
); );
} }
// Check all-time bounce rate // Check all-time complaint rate - requires BOTH rate AND absolute count
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) { if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push( violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`, `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
); );
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) { } else if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push( warnings.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`, `All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
);
}
// Check 7-day complaint rate
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
);
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
);
}
// Check all-time complaint rate
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
violations.push(
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
);
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
warnings.push(
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
); );
} }
} }
@@ -408,11 +429,8 @@ export class SecurityService {
// Send email notification to project members // Send email notification to project members
try { try {
const members = await prisma.membership.findMany({ const members = await MembershipService.getMembers(projectId);
where: {projectId}, const emails = members.map(m => m.email);
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length > 0) { if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, { const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name, projectName: project.name,
+155 -33
View File
@@ -1,5 +1,6 @@
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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -8,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"
@@ -72,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;
@@ -90,7 +82,7 @@ export class SegmentService {
]); ]);
return { return {
contacts, data: contacts,
total, total,
page, page,
pageSize, pageSize,
@@ -122,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,
}, },
@@ -169,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);
@@ -237,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});
@@ -268,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});
@@ -298,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
@@ -364,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) {
@@ -496,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)
*/ */
@@ -551,8 +584,10 @@ export class SegmentService {
'exists', 'exists',
'notExists', 'notExists',
'within', 'within',
'olderThan',
'triggered', 'triggered',
'triggeredWithin', 'triggeredWithin',
'triggeredOlderThan',
'notTriggered', 'notTriggered',
]; ];
@@ -571,7 +606,9 @@ export class SegmentService {
'greaterThanOrEqual', 'greaterThanOrEqual',
'lessThanOrEqual', 'lessThanOrEqual',
'within', 'within',
'olderThan',
'triggeredWithin', 'triggeredWithin',
'triggeredOlderThan',
]; ];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) { if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
@@ -579,7 +616,7 @@ export class SegmentService {
} }
// Validate unit for time-based operators // Validate unit for time-based operators
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) { if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`); throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
} }
} }
@@ -594,19 +631,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)
*/ */
@@ -709,6 +733,20 @@ export class SegmentService {
// Use ISO string for lexicographic comparison in JSON // Use ISO string for lexicographic comparison in JSON
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}}; return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
} }
case 'olderThan': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
throw new HttpException(400, 'Unit is required for "olderThan" operator');
}
// Calculate the "before" date (X time units ago from now)
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
// Use ISO string for lexicographic comparison in JSON
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
}
default: default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`); throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
} }
@@ -802,6 +840,18 @@ export class SegmentService {
return {[field]: {gte: since}}; return {[field]: {gte: since}};
} }
case 'olderThan': {
// "olderThan X days/hours/minutes" means more than X time units ago
if (!unit) {
throw new HttpException(400, 'Unit is required for "olderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {[field]: {lt: before}};
}
default: default:
throw new HttpException(400, `Unsupported operator for date field: ${operator}`); throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
} }
@@ -901,6 +951,42 @@ export class SegmentService {
}; };
} }
case 'triggeredOlderThan': {
// Contact triggered this event, but only more than X time ago (not recently)
// This means: has event AND all occurrences are before the cutoff
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {
AND: [
// Must have triggered the event at some point
{
events: {
some: {
name: eventName,
},
},
},
// But NOT within the recent timeframe
{
events: {
none: {
name: eventName,
createdAt: {
gte: before,
},
},
},
},
],
};
}
case 'notTriggered': case 'notTriggered':
// Contact has never triggered this event // Contact has never triggered this event
return { return {
@@ -975,6 +1061,42 @@ export class SegmentService {
}; };
} }
case 'triggeredOlderThan': {
// Contact had this email activity, but only more than X time ago (not recently)
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {
AND: [
// Must have the email activity at some point
{
emails: {
some: {
[field]: {
not: null,
},
},
},
},
// But NOT within the recent timeframe
{
emails: {
none: {
[field]: {
gte: before,
},
},
},
},
],
};
}
case 'notTriggered': case 'notTriggered':
// Contact has never had this email activity // Contact has never had this email activity
return { return {
+3 -10
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,
@@ -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, }),
}, },
}); });
@@ -517,18 +519,25 @@ export class WorkflowExecutionService {
} }
/** /**
* SEND_EMAIL step - Send an email to the contact * SEND_EMAIL step - Send an email to the contact or a custom recipient
*/ */
private static async executeSendEmail( private static async executeSendEmail(
step: WorkflowStepWithTemplate, step: WorkflowStepWithTemplate,
execution: WorkflowExecutionWithRelations, execution: WorkflowExecutionWithRelations,
stepExecution: WorkflowStepExecution, stepExecution: WorkflowStepExecution,
_config: StepConfig, config: StepConfig,
): Promise<StepResult> { ): Promise<StepResult> {
if (!step.template) { if (!step.template) {
throw new Error('No template configured for SEND_EMAIL step'); throw new Error('No template configured for SEND_EMAIL step');
} }
// Parse step config to get recipient configuration
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
const recipientConfig =
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
? (stepConfig.recipient as {type?: string; customEmail?: string})
: {type: 'CONTACT'};
// Get contact data for variable substitution // Get contact data for variable substitution
const contact = execution.contact; const contact = execution.contact;
const contactData = const contactData =
@@ -543,6 +552,7 @@ export class WorkflowExecutionService {
: {}; : {};
const variables = { const variables = {
id: contact.id,
email: contact.email, email: contact.email,
...contactData, ...contactData,
...executionContext, ...executionContext,
@@ -555,10 +565,20 @@ export class WorkflowExecutionService {
const renderedSubject = this.renderTemplate(step.template.subject, variables); const renderedSubject = this.renderTemplate(step.template.subject, variables);
const renderedBody = this.renderTemplate(step.template.body, variables); const renderedBody = this.renderTemplate(step.template.body, variables);
// Determine recipient email
let recipientEmail = contact.email;
let recipientContactId = contact.id;
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
recipientEmail = recipientConfig.customEmail;
// For custom recipients, we don't associate with a contact
recipientContactId = contact.id; // Keep original contact for tracking
}
// Send email via EmailService // Send email via EmailService
const email = await EmailService.sendWorkflowEmail({ const email = await EmailService.sendWorkflowEmail({
projectId: execution.workflow.projectId, projectId: execution.workflow.projectId,
contactId: contact.id, contactId: recipientContactId,
workflowExecutionId: execution.id, workflowExecutionId: execution.id,
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
templateId: step.template.id, templateId: step.template.id,
@@ -567,11 +587,15 @@ export class WorkflowExecutionService {
from: step.template.from, from: step.template.from,
fromName: step.template.fromName || undefined, fromName: step.template.fromName || undefined,
replyTo: step.template.replyTo || undefined, replyTo: step.template.replyTo || undefined,
// Pass custom recipient email if specified
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
}); });
return { return {
emailId: email.id, emailId: email.id,
sentAt: email.createdAt, sentAt: email.createdAt,
recipientType: recipientConfig.type,
recipientEmail,
}; };
} }
@@ -866,7 +890,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,
}, },
}); });
+15 -31
View File
@@ -1,5 +1,7 @@
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 signale from 'signale';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
@@ -10,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 = {
@@ -71,7 +55,7 @@ export class WorkflowService {
]); ]);
return { return {
workflows: workflows as Workflow[], data: workflows as Workflow[],
total, total,
page, page,
pageSize, pageSize,
@@ -321,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,
}, },
}); });
@@ -403,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};
@@ -596,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),
}, },
}, },
}); });
@@ -1,4 +1,4 @@
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';
@@ -320,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,5 +1,5 @@
import {describe, it, expect, beforeEach, vi} 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';
@@ -164,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);
@@ -177,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);
}); });
}); });
@@ -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';
@@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false}); const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false}); const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [ await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
p1Contact1.id,
p1Contact2.id,
p2Contact1.id,
p2Contact2.id,
]);
const p1ContactsAfter = await prisma.contact.findMany({ const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id}, where: {projectId: project1.id},
@@ -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 () => {
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match exact string values in standard fields (case-insensitive)', async () => { it('should match exact string values in standard fields (case-insensitive)', async () => {
@@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match boolean values', async () => { it('should match boolean values', async () => {
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match numeric values as strings in JSON fields', async () => { it('should match numeric values as strings in JSON fields', async () => {
@@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 exclude boolean false values', async () => { it('should exclude boolean false values', async () => {
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 NOT include contacts where field does not exist (only excludes matching values)', async () => { it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
@@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
// notEquals only matches where field exists and has different value // notEquals only matches where field exists and has different value
expect(ids).toContain(withDifferentValue.id); expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingField.id); expect(ids).not.toContain(withMatchingField.id);
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match substring in email field (case-insensitive)', async () => { it('should match substring in email field (case-insensitive)', async () => {
@@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match1.id); expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should not match when field does not exist', async () => { it('should not match when field does not exist', async () => {
@@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should match partial domain in email', async () => { it('should match partial domain in email', async () => {
@@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(gmailUser.id); expect(result.data[0].id).toBe(gmailUser.id);
}); });
}); });
@@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 NOT include contacts where field does not exist (only excludes matching substrings)', async () => { it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
@@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
// notContains only matches where field exists and doesn't contain substring // notContains only matches where field exists and doesn't contain substring
expect(ids).toContain(withDifferentValue.id); expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingSubstring.id); expect(ids).not.toContain(withMatchingSubstring.id);
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
}); });
@@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(high.id); expect(ids).toContain(high.id);
expect(ids).toContain(veryHigh.id); expect(ids).toContain(veryHigh.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should exclude values equal to threshold', async () => { it('should exclude values equal to threshold', async () => {
@@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should work with negative numbers', async () => { it('should work with negative numbers', async () => {
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 work with decimal values', async () => { it('should work with decimal values', async () => {
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(equal.id); expect(ids).toContain(equal.id);
expect(ids).toContain(greater.id); expect(ids).toContain(greater.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
}); });
@@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(low.id); expect(ids).toContain(low.id);
expect(ids).toContain(veryLow.id); expect(ids).toContain(veryLow.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
it('should exclude values equal to threshold', async () => { it('should exclude values equal to threshold', async () => {
@@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(equal.id); expect(ids).toContain(equal.id);
expect(ids).toContain(less.id); expect(ids).toContain(less.id);
expect(result.contacts).toHaveLength(2); expect(result.data).toHaveLength(2);
}); });
}); });
@@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(positive.id); expect(result.data[0].id).toBe(positive.id);
}); });
it('should handle very large numbers', async () => { it('should handle very large numbers', async () => {
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
}); });
@@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 exclude contacts where field is null', async () => { it('should exclude contacts where field is null', async () => {
@@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withValue.id); expect(result.data[0].id).toBe(withValue.id);
}); });
it('should match fields with empty string values', async () => { it('should match fields with empty string values', async () => {
@@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withEmptyString.id); expect(result.data[0].id).toBe(withEmptyString.id);
}); });
it('should match fields with zero values', async () => { it('should match fields with zero values', async () => {
@@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withZero.id); expect(result.data[0].id).toBe(withZero.id);
}); });
it('should match fields with boolean false values', async () => { it('should match fields with boolean false values', async () => {
@@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withFalse.id); expect(result.data[0].id).toBe(withFalse.id);
}); });
}); });
@@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withoutField.id); expect(result.data[0].id).toBe(withoutField.id);
}); });
it('should match contacts where field is null', async () => { it('should match contacts where field is null', async () => {
@@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(withNull.id); expect(result.data[0].id).toBe(withNull.id);
}); });
it('should exclude fields with empty string values', async () => { it('should exclude fields with empty string values', async () => {
@@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should exclude fields with zero values', async () => { it('should exclude fields with zero values', async () => {
@@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
}); });
@@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(veryRecent.id); expect(ids).toContain(veryRecent.id);
}); });
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(justNow.id); expect(ids).toContain(justNow.id);
}); });
}); });
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match contacts with JSON date field within specified hours', async () => { it('should match contacts with JSON date field within specified hours', async () => {
@@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match contacts with JSON date field within specified minutes', async () => { it('should match contacts with JSON date field within specified minutes', async () => {
@@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 NOT match contacts with JSON date field outside the time range', async () => { it('should NOT match contacts with JSON date field outside the time range', async () => {
@@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should NOT match contacts where JSON date field does not exist', async () => { it('should NOT match contacts where JSON date field does not exist', async () => {
@@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should handle null values in JSON date fields gracefully', async () => { it('should handle null values in JSON date fields gracefully', async () => {
@@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should work correctly with combined filters (AND logic)', async () => { it('should work correctly with combined filters (AND logic)', async () => {
@@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 require unit parameter for within operator', async () => { it('should require unit parameter for within operator', async () => {
@@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(newer.id); expect(ids).toContain(newer.id);
expect(ids).not.toContain(older.id); expect(ids).not.toContain(older.id);
}); });
@@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(first.id); expect(ids).toContain(first.id);
expect(ids).toContain(second.id); expect(ids).toContain(second.id);
expect(ids).not.toContain(third.id); expect(ids).not.toContain(third.id);
@@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match1.id); expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id); expect(ids).not.toContain(noMatch.id);
@@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match1.id); expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id); expect(ids).not.toContain(noMatch.id);
@@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match.id); expect(ids).toContain(match.id);
}); });
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match1.id); expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id); expect(ids).not.toContain(noMatch.id);
@@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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(match1.id); expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id); expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id); expect(ids).not.toContain(noMatch.id);
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 combine existence checks with value comparisons', async () => { it('should combine existence checks with value comparisons', async () => {
@@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 not match contacts who have not triggered the event', async () => { it('should not match contacts who have not triggered the event', async () => {
@@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should match contacts with multiple occurrences of the same event', async () => { it('should match contacts with multiple occurrences of the same event', async () => {
@@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match contacts with event within time range (hours)', async () => { it('should match contacts with event within time range (hours)', async () => {
@@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 match contacts with event within time range (minutes)', async () => { it('should match contacts with event within time range (minutes)', async () => {
@@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 handle events at exact boundary', async () => { it('should handle events at exact boundary', async () => {
@@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
const result = await SegmentService.getContacts(projectId, segment.id); const result = await SegmentService.getContacts(projectId, segment.id);
// Should not match because events at exact boundary are excluded // Should not match because events at exact boundary are excluded
expect(result.contacts).toHaveLength(0); expect(result.data).toHaveLength(0);
}); });
it('should not match contacts with event outside time range', async () => { it('should not match contacts with event outside time range', async () => {
@@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should match contact if any of their events is within range', async () => { it('should match contact if any of their events is within range', async () => {
@@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 not match contacts who have triggered the event', async () => { it('should not match contacts who have triggered the event', async () => {
@@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
it('should match contacts with other events but not the target event', async () => { it('should match contacts with other events but not the target event', async () => {
@@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
@@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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 combine multiple event filters', async () => { it('should combine multiple event filters', async () => {
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
}); });
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);
}); });
}); });
}); });
@@ -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';
@@ -205,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);
}); });
@@ -218,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 () => {
@@ -234,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);
+31
View File
@@ -26,6 +26,12 @@ 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: { Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string { usage(projectId: string, sourceType: string, year: number, month: string): string {
@@ -64,4 +70,29 @@ export const Keys = {
return `workflows:enabled:${projectId}`; 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;
+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.useplunk.com', siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
generateRobotsTxt: true, generateRobotsTxt: true,
}; };
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0", "@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3", "juice": "^11.0.3",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"next": "^16.0.10", "next": "^16.1.5",
"next-seo": "^6.6.0", "next-seo": "^6.6.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
@@ -25,7 +25,7 @@ export function GuideLayout({
readTime, readTime,
children, children,
canonical, canonical,
ogImage = 'https://www.useplunk.com/assets/card.png', ogImage = 'https://next.useplunk.com/assets/card.png',
}: GuideLayoutProps) { }: GuideLayoutProps) {
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]); const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>(''); const [activeId, setActiveId] = useState<string>('');
@@ -74,8 +74,8 @@ export function GuideLayout({
// Generate breadcrumb items // Generate breadcrumb items
const breadcrumbItems = [ const breadcrumbItems = [
{position: 1, name: 'Home', item: 'https://www.useplunk.com'}, {position: 1, name: 'Home', item: 'https://next.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'}, {position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
{position: 3, name: title, item: canonical || ''}, {position: 3, name: title, item: canonical || ''},
]; ];
@@ -1,5 +1,17 @@
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react'; import {
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification'; AlertCircle,
AlertTriangle,
CheckCircle,
Forward,
Info,
Mail,
Server,
Shield,
Trash2,
User,
XCircle,
} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
interface EmailVerificationResultProps { interface EmailVerificationResultProps {
result: VerificationResult; result: VerificationResult;
@@ -41,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
<Server className="h-5 w-5 text-neutral-600" /> <Server className="h-5 w-5 text-neutral-600" />
<div> <div>
<p className="font-medium text-neutral-900">Domain Exists</p> <p className="font-medium text-neutral-900">Domain Exists</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p> <p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
</div> </div>
</div> </div>
{result.domainExists ? ( {result.domainExists ? (
@@ -67,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)} )}
</div> </div>
{/* Website/A Records */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Has Website</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
</div>
</div>
{result.hasWebsite ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Info className="h-5 w-5 text-neutral-400" />
)}
</div>
{/* Disposable Email */} {/* Disposable Email */}
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -83,6 +111,38 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)} )}
</div> </div>
{/* Personal Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<User className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Personal Email</p>
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
</div>
</div>
{result.isPersonalEmail ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<span className="text-sm text-neutral-500">No</span>
)}
</div>
{/* Alias/Forwarding Email */}
<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 */} {/* Typo Detection */}
<div className="flex items-center justify-between px-6 py-4"> <div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
const fragment = convertToEmailHtml(html); const fragment = convertToEmailHtml(html);
return wrapEmailHtml(fragment); return wrapEmailHtml(fragment);
} }
+1 -11
View File
@@ -1,14 +1,4 @@
export interface EmailVerificationResult { import type {EmailVerificationResult} from '@plunk/types';
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
export async function verifyEmail(email: string): Promise<EmailVerificationResult> { export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
const response = await fetch('/api/verify-email', { const response = await fetch('/api/verify-email', {
+6 -1
View File
@@ -1,4 +1,4 @@
import {CheckCircle, Mail, Search, Shield} from 'lucide-react'; import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
/** /**
* Educational content for the email verification tool * Educational content for the email verification tool
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
description: 'Identify temporary email addresses that are often used for spam or fake signups.', description: 'Identify temporary email addresses that are often used for spam or fake signups.',
icon: Mail, icon: Mail,
}, },
{
title: 'Personal Email Detection',
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
icon: User,
},
{ {
title: 'Plus Addressing', title: 'Plus Addressing',
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.', description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
+1 -1
View File
@@ -63,7 +63,7 @@ export default function WithProviders(props: AppProps) {
title: 'Plunk | The Open-Source Email Platform', title: 'Plunk | The Open-Source Email Platform',
description: description:
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.', 'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}], images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
}} }}
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]} additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
/> />
+3 -14
View File
@@ -1,26 +1,15 @@
import type {NextApiRequest, NextApiResponse} from 'next'; import type {NextApiRequest, NextApiResponse} from 'next';
import type {EmailVerificationResult} from '@plunk/types';
import {UtilitySchemas} from '@plunk/shared'; import {UtilitySchemas} from '@plunk/shared';
import {API_URI} from '../../lib/constants'; import {API_URI} from '../../lib/constants';
interface VerifyEmailResponse {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
interface ErrorResponse { interface ErrorResponse {
error: string; error: string;
} }
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>, res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
) { ) {
// Only allow POST requests // Only allow POST requests
if (req.method !== 'POST') { if (req.method !== 'POST') {
@@ -49,7 +38,7 @@ export default async function handler(
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${secretKey}`, 'Authorization': `Bearer ${secretKey}`,
}, },
body: JSON.stringify({email}), body: JSON.stringify({email}),
}); });
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application." description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="12 min" readTime="12 min"
canonical="https://www.useplunk.com/guides/email-api-guide" canonical="https://next.useplunk.com/guides/email-api-guide"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -132,7 +132,7 @@ export default function EmailAPIGuide() {
language="javascript" language="javascript"
title="Node.js Example" title="Node.js Example"
code={`// Using fetch (Node.js 18+ or with node-fetch) code={`// Using fetch (Node.js 18+ or with node-fetch)
const response = await fetch('https://api.useplunk.com/v1/send', { const response = await fetch('https://next-api.useplunk.com/v1/send', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -167,7 +167,7 @@ if (response.ok) {
code={`import requests code={`import requests
response = requests.post( response = requests.post(
'https://api.useplunk.com/v1/send', 'https://next-api.useplunk.com/v1/send',
headers={ headers={
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY' 'Authorization': 'Bearer YOUR_API_KEY'
@@ -195,7 +195,7 @@ else:
language="php" language="php"
title="PHP Example" title="PHP Example"
code={`<?php code={`<?php
$ch = curl_init('https://api.useplunk.com/v1/send'); $ch = curl_init('https://next-api.useplunk.com/v1/send');
curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -235,7 +235,7 @@ if ($httpCode === 200) {
code={`require 'net/http' code={`require 'net/http'
require 'json' require 'json'
uri = URI('https://api.useplunk.com/v1/send') uri = URI('https://next-api.useplunk.com/v1/send')
http = Net::HTTP.new(uri.host, uri.port) http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true http.use_ssl = true
@@ -295,7 +295,7 @@ func main() {
jsonData, _ := json.Marshal(email) jsonData, _ := json.Marshal(email)
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData)) req, _ := http.NewRequest("POST", "https://next-api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer YOUR_API_KEY") req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation." description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="9 min" readTime="9 min"
canonical="https://www.useplunk.com/guides/email-bounce-rate" canonical="https://next.useplunk.com/guides/email-bounce-rate"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement." description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="10 min" readTime="10 min"
canonical="https://www.useplunk.com/guides/email-click-through-rate" canonical="https://next.useplunk.com/guides/email-click-through-rate"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices." description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="12 min" readTime="12 min"
canonical="https://www.useplunk.com/guides/email-deliverability" canonical="https://next.useplunk.com/guides/email-deliverability"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -306,9 +306,7 @@ export default function EmailDeliverability() {
{/* Monitoring */} {/* Monitoring */}
<section id="monitoring" className="mb-12"> <section id="monitoring" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2> <h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
<p className="text-neutral-700 leading-relaxed mb-6"> <p className="text-neutral-700 leading-relaxed mb-6">Track these metrics to stay on top of deliverability:</p>
Track these metrics to stay on top of deliverability:
</p>
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6"> <div className="rounded-xl border border-neutral-200 bg-white p-6">
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025." description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="15 min" readTime="15 min"
canonical="https://www.useplunk.com/guides/email-marketing-best-practices" canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips." description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="10 min" readTime="10 min"
canonical="https://www.useplunk.com/guides/email-open-rate" canonical="https://next.useplunk.com/guides/email-open-rate"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability." description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="11 min" readTime="11 min"
canonical="https://www.useplunk.com/guides/email-sender-reputation" canonical="https://next.useplunk.com/guides/email-sender-reputation"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
+3 -3
View File
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
<NextSeo <NextSeo
title="Email Marketing & Deliverability Guides | Plunk" title="Email Marketing & Deliverability Guides | Plunk"
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk." description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
canonical="https://www.useplunk.com/guides" canonical="https://next.useplunk.com/guides"
openGraph={{ openGraph={{
title: 'Email Marketing & Deliverability Guides | Plunk', title: 'Email Marketing & Deliverability Guides | Plunk',
description: description:
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.', 'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
url: 'https://www.useplunk.com/guides', url: 'https://next.useplunk.com/guides',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}], images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
}} }}
/> />
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices." description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="10 min" readTime="10 min"
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email" canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples." description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="8 min" readTime="8 min"
canonical="https://www.useplunk.com/guides/what-is-dkim" canonical="https://next.useplunk.com/guides/what-is-dkim"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples." description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="9 min" readTime="9 min"
canonical="https://www.useplunk.com/guides/what-is-dmarc" canonical="https://next.useplunk.com/guides/what-is-dmarc"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices." description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
lastUpdated="2025-12-20" lastUpdated="2025-12-20"
readTime="7 min" readTime="7 min"
canonical="https://www.useplunk.com/guides/what-is-spf" canonical="https://next.useplunk.com/guides/what-is-spf"
> >
{/* Introduction */} {/* Introduction */}
<section id="introduction" className="mb-12"> <section id="introduction" className="mb-12">

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