Compare commits

...
189 Commits
Author SHA1 Message Date
Dries AugustynsandGitHub cf6ad0b8e0 Merge pull request #241 from useplunk/release-please--branches--next--components--plunk 2025-12-29 21:23:49 +01:00
Dries Augustyns 782dc5754d chore: Add Plunk API key to entrypoint 2025-12-29 20:09:35 +01:00
Dries Augustyns cf88680a13 chore: Add Plunk API key to entrypoint 2025-12-29 20:01:18 +01:00
Dries Augustyns 6293259225 chore: Add free tools on marketing pages 2025-12-29 19:22:35 +01:00
github-actions[bot]andGitHub a8678b8842 chore(next): release 0.3.0 2025-12-29 14:52:10 +00:00
Dries Augustyns 5993b842a0 fix: Add styling for visual editor emails in preview 2025-12-29 15:51:39 +01:00
Dries Augustyns 78cef418a1 chore: Copy over manifest files 2025-12-29 15:42:18 +01:00
Dries Augustyns d56ba0cf9b chore: Resolve linting warnings 2025-12-29 15:29:29 +01:00
Dries Augustyns 72dffe12e5 feat: Email preview in contact and activity feed 2025-12-29 15:26:53 +01:00
Dries Augustyns 2d353ef839 chore: Do not dockerignore scripts 2025-12-29 14:27:04 +01:00
Dries Augustyns f26ef53c8c chore: Improve startup speed of containers 2025-12-29 14:23:21 +01:00
Dries Augustyns 98b95b2ab2 chore: Add additional comparison pages 2025-12-29 11:53:08 +01:00
Dries Augustyns bc8611a662 feat: Add additional banner and information about security metrics 2025-12-28 14:00:15 +01:00
Dries Augustyns 2ddfceca36 docs: Improve docs with core-concept and guides 2025-12-28 10:46:46 +01:00
Dries Augustyns 862babb8f5 fix: Set auth type before disable check 2025-12-27 14:40:28 +01:00
Dries Augustyns 61cea95697 fix: Correctly reserve fields from being set on contact 2025-12-25 17:59:52 +01:00
Dries Augustyns 1fc1e23fce docs: Add locale overwrite to project documentation 2025-12-25 17:53:58 +01:00
Dries Augustyns 76155232a3 feat: Ability to overwrite locale on contact level with locale key on data 2025-12-25 17:37:25 +01:00
Dries AugustynsandGitHub fa9e9de032 Merge pull request #249 from manjhss/feat/i18n-german-locale 2025-12-25 16:59:43 +01:00
Surendra Manjhi a6bd2e7dba feat(i18n): add German translations and update supported languages 2025-12-25 16:20:55 +05:30
Dries AugustynsandGitHub e58dc1d056 Merge pull request #248 from manish-raana/feat/add-hindi-translations 2025-12-25 08:43:29 +01:00
Manish Rana ddc14ae853 feat(i18n): add Hindi translations for contact-facing pages
- Add hi.json locale file with Hindi translations
- Add Hindi language to SUPPORTED_LANGUAGES
- Import Hindi translations in i18n index

Closes #246
2025-12-24 23:59:35 +05:30
Dries Augustyns 0d54b1a631 docs: Add plus address check to /v1/verify 2025-12-24 12:18:53 +01:00
Dries Augustyns afc405ec02 feat: Add plus address check to /v1/verify 2025-12-24 12:14:39 +01:00
Dries Augustyns 6a9f6aa65a feat: Add email verification endpoint at /v1/verify 2025-12-24 09:54:50 +01:00
Dries Augustyns 8f725c7c84 fix: Properly tag events in SegmentFilterBuilder.tsx 2025-12-23 10:52:03 +01:00
Dries Augustyns 97ab0a2c2c fix: Date filtering not working properly for custom contact data 2025-12-22 21:39:15 +01:00
Dries AugustynsandGitHub ddd58c20aa Merge pull request #247 from benoiteveillard/feat/i18n-french-locale 2025-12-22 10:33:29 +01:00
Benoît Eveillard 489c465599 i18n: add French (fr) locale 2025-12-22 09:46:28 +01:00
Dries Augustyns 451dd0327f fix: Import no longer case-sensitive about email column 2025-12-21 16:43:00 +01:00
Dries Augustyns e1f826357d feat: Add project-scoped language for unsubscribe footer and contact-facing pages 2025-12-21 13:09:21 +01:00
Dries Augustyns 8a136dde55 feat: Allow to pick currency when starting subscription 2025-12-21 11:53:32 +01:00
Dries Augustyns cc9b0f8a73 chore: Remove incorrect information from SEO schemas 2025-12-21 11:00:47 +01:00
Dries Augustyns 3cada63f15 chore: Correct linting errors in sitemap 2025-12-21 09:12:29 +01:00
Dries Augustyns 7fdcb22515 chore: Introduce sitemap for wiki 2025-12-21 09:07:14 +01:00
Dries Augustyns d7b5d3f60e fix: Pass through email verification if auth type is apiKey 2025-12-20 21:40:26 +01:00
Dries Augustyns 24f82b1be5 chore: Improve dropdown on mobile Navbar.tsx 2025-12-20 21:05:15 +01:00
Dries Augustyns 708dc81b88 chore: Make guide pages responsive on small screens 2025-12-20 21:03:17 +01:00
Dries Augustyns 35f5275d88 fix: Redirect verification link to dashboard instead of landing 2025-12-20 20:41:26 +01:00
Dries Augustyns 9567144390 fix: Do not check verification if platform emails are not enabled 2025-12-20 20:12:43 +01:00
Dries Augustyns 1a5607f278 feat: Add email verification and password reset 2025-12-20 20:06:25 +01:00
Dries Augustyns 7e25c148cc chore: Add index pages for /vs 2025-12-20 11:47:12 +01:00
Dries Augustyns 122713c39a chore: Resolve sitemap config by turning it into .cjs 2025-12-20 11:27:08 +01:00
Dries Augustyns 070fd812b2 chore: Ignore linting error due to useLayoutEffect 2025-12-20 11:18:46 +01:00
Dries Augustyns 4156cd436d chore: Add guides and comparison pages to landing site 2025-12-20 11:05:45 +01:00
Dries Augustyns 996852a506 chore: Additional pages 2025-12-19 19:20:30 +01:00
Dries Augustyns 2fb588357a chore: Add free tier to landing page 2025-12-19 17:25:28 +01:00
Dries Augustyns c3a276cde3 chore: Add additional links and shields 2025-12-19 14:20:10 +01:00
Dries Augustyns 661bf2a9e7 style: Remove shadow from input element for consistency 2025-12-19 13:40:52 +01:00
Dries Augustyns 007a908e83 fix: Persistence of subscription state for existing contacts 2025-12-18 19:43:59 +01:00
Dries AugustynsandGitHub 23b4ec992d Merge pull request #245 from hemanth5544/next 2025-12-18 16:13:00 +01:00
Dries Augustyns 726f66762b feat: Add bulk actions to contact overview 2025-12-18 16:12:42 +01:00
Hemanth Rachapalli 3b77ca5570 style: Remove active line background color in HtmlEditor 2025-12-18 18:44:16 +05:30
Dries Augustyns 8c0304273c fix: Variable substitution in transactional emails 2025-12-17 20:00:27 +01:00
Dries Augustyns dc9b88dedb docs: Update openapi.json to match actual API outputs 2025-12-17 08:01:17 +01:00
Dries AugustynsandGitHub 409d1b83eb Merge pull request #231 from useplunk/release-please--branches--next--components--plunk 2025-12-16 16:24:37 +01:00
github-actions[bot]andGitHub c3bd5d1d5f chore(next): release 0.2.0 2025-12-16 12:51:03 +00:00
Dries Augustyns 3336fa1e38 fix: Overflow of inputs in email editor 2025-12-16 13:50:32 +01:00
Dries Augustyns 769e3748a0 fix: Increase z-index of color picker 2025-12-16 13:19:52 +01:00
Dries Augustyns 91eb0f3a69 fix: Better highlight warnings in SecurityWarningBanner.tsx 2025-12-16 12:00:33 +01:00
Dries Augustyns 863e784e1c fix: Add additional checks for disabled projects 2025-12-16 11:52:53 +01:00
Dries Augustyns 780741e37f fix: Add additional checks for disabled projects 2025-12-15 07:41:21 +01:00
Dries Augustyns e50c33ae4b fix: Clear notification cache keys when changing billing limits 2025-12-13 10:28:11 +01:00
Dries Augustyns f34052ed73 fix: Prevent manual tracking of internal events that are automatically tracked 2025-12-12 20:30:49 +01:00
Dries Augustyns a555a12736 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:11 +01:00
Dries Augustyns f37bfccbc2 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:02 +01:00
Dries Augustyns db1496e525 chore: remove module config 2025-12-12 12:44:04 +01:00
Dries Augustyns 443c6271f1 chore: Add email package to vitest config 2025-12-12 12:38:58 +01:00
Dries Augustyns 9cf5222c8c chore: Update turbo.json and example docker-compose to include Plunk .env variables 2025-12-12 12:31:30 +01:00
Dries Augustyns 2485d2ff1d feat: Added platform emails for billing limits and disabled projects 2025-12-12 12:28:53 +01:00
Dries Augustyns cb40669385 chore: Remove accidental package-lock.json 2025-12-12 08:16:25 +01:00
Dries AugustynsandGitHub d9e7f9f5cf Merge pull request #238 from Nithur-M/next 2025-12-12 08:14:25 +01:00
Dries Augustyns dafa90f0dd chore: Also updated react and react-dom versions 2025-12-12 08:05:10 +01:00
nithur-m 9737c2b67d update to next 16.0.10 2025-12-12 09:04:57 +05:30
Dries Augustyns 1cd89d1375 fix: add additional indexes on event model 2025-12-11 19:09:54 +01:00
Dries Augustyns 11ef47b576 fix: Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages 2025-12-11 18:41:37 +01:00
Dries Augustyns fbd303801f feat: Add security center and warning for exceeding bounce/complaint rates 2025-12-11 18:34:30 +01:00
Dries AugustynsandGitHub 0ecd82d62e Merge pull request #234 from Nithur-M/next 2025-12-11 18:24:12 +01:00
nithur-m 1452cbd591 format fulldate for campaign sent time 2025-12-11 20:03:01 +05:30
Dries Augustyns 07f875c18c fix: display email progress instead of scheduling progress for campaigns 2025-12-11 14:36:26 +01:00
Dries Augustyns 5e4adb71ad fix: custom relative time to shorten strings for better UI fit 2025-12-11 11:13:39 +01:00
Dries Augustyns 1019ba0d82 feat: Improved createdAt and updatedAt visualisation 2025-12-11 10:57:56 +01:00
Dries Augustyns 6b25bbe2f8 feat: ability to create new campaigns based on templates or previous campaigns 2025-12-10 21:04:51 +01:00
Dries Augustyns b9758149f5 chore: update description in subscription card 2025-12-10 20:30:27 +01:00
Dries Augustyns d438abf1a5 Merge branch 'next' of https://github.com/useplunk/plunk into next 2025-12-10 19:53:26 +01:00
Dries Augustyns 9375c598ee chore: add indicator about campaign recipients recalculation 2025-12-10 19:52:09 +01:00
Dries AugustynsandGitHub 8f636cc5b9 Merge pull request #232 from Nithur-M/next 2025-12-10 16:07:03 +01:00
Dries Augustyns 1430f31e2d chore: replace standard logging with signale 2025-12-10 14:21:42 +01:00
Dries Augustyns 3225c5005b feat: Automatically detect rate limit from AWS with ability to override in .env 2025-12-10 14:03:30 +01:00
nithur-m f36b213f0a show text left on projects dropdown 2025-12-10 17:03:49 +05:30
Dries Augustyns cb15fad7da test: properly mock Stripe in CampaignService.test.ts 2025-12-09 19:05:22 +01:00
Dries Augustyns a3bbb1ed64 fix: prevent scheduling of campaign if billing limit reached 2025-12-09 18:50:05 +01:00
Dries Augustyns 04275e3cf5 fix: show correct default value for placeholder 2025-12-09 15:57:54 +01:00
Dries Augustyns badb035585 fix: ability to clear reply-to and from name from templates and campaigns 2025-12-09 15:47:27 +01:00
Dries Augustyns c12d0c0898 test: Correctly mock STRIPE_ENABLED 2025-12-09 15:23:48 +01:00
Dries Augustyns 6e9f11f6a7 test: Correctly mock STRIPE_ENABLED 2025-12-09 14:52:40 +01:00
Dries Augustyns 1713b2398d fix: Only check free tier limits if billing is enabled 2025-12-09 14:26:59 +01:00
Dries Augustyns 68f99798f8 fix: Verify if sending without tracking is possible in SESService 2025-12-09 14:10:47 +01:00
Dries Augustyns 5a0a41c6bb fix: Only fetch project members if project Id is defined 2025-12-09 13:24:54 +01:00
Dries Augustyns 129a496206 chore: linting error 2025-12-09 11:24:13 +01:00
Dries Augustyns d0191be31d fix: Correctly show recipient count during creation and edit 2025-12-09 11:08:53 +01:00
Dries Augustyns 85b9d7afe7 fix: Allow changing audience type after creation of campaign 2025-12-09 10:40:42 +01:00
Dries Augustyns ff5c79cfcf fix: Update recipient count on create/update of campaign 2025-12-09 10:27:56 +01:00
Dries Augustyns 672f1e6657 feat: Add improved html editor using CodeMirror 2025-12-09 10:15:33 +01:00
Dries Augustyns 433e796c04 fix: Hide upsell banner if billing is not configured 2025-12-09 08:45:53 +01:00
Dries Augustyns 77089fcdd6 style: Hide labels of tabs on smaller screens 2025-12-08 21:48:09 +01:00
Dries Augustyns a372e55b78 style: Added placeholder to EmailEditor.tsx 2025-12-08 17:45:08 +01:00
Dries Augustyns 8ec0f50f68 chore: Set version of local database to postgres-16 to align with production template 2025-12-08 17:40:24 +01:00
Dries AugustynsandGitHub dbf58046e7 Merge pull request #230 from useplunk/release-please--branches--next--components--plunk 2025-12-08 15:29:57 +01:00
github-actions[bot]andGitHub d238965c2c chore(next): release 0.1.1 2025-12-08 14:29:25 +00:00
Dries Augustyns 53ecda9f7a fix: Add additional verification in Oauth controllers 2025-12-08 15:29:04 +01:00
Dries Augustyns fc535a558f fix: Dedicated token name for next version 2025-12-08 14:31:13 +01:00
Dries Augustyns f7ac25f91f docs: Update AWS setup docs 2025-12-08 14:23:08 +01:00
Dries Augustyns 9d8b4a59fb fix: only mark releases as latest 2025-12-08 13:26:41 +01:00
Dries Augustyns 60804c12b0 fix: add clear cache button on full-screen loader 2025-12-08 13:11:59 +01:00
Dries Augustyns 90f8170efb docs: dynamic link to Docker Compose for self-hosting 2025-12-08 12:39:09 +01:00
Dries AugustynsandGitHub 1a10448af3 Merge pull request #229 from useplunk/release-please--branches--next--components--plunk 2025-12-08 11:33:06 +01:00
github-actions[bot]andGitHub e10662a1a1 chore(next): release 0.1.0 2025-12-08 08:28:37 +00:00
Dries Augustyns 6746d9e843 Only notify segment changes when amount has changed 2025-12-08 09:26:36 +01:00
Dries Augustyns 82e846e993 Add improved filtering in conditions block of workflows 2025-12-07 17:35:44 +01:00
Dries Augustyns 4d7c68db3a Use alert instead of custom component 2025-12-07 16:29:38 +01:00
Dries Augustyns 16572b8f49 Remove emojis and improve descriptions 2025-12-07 16:25:34 +01:00
Dries Augustyns caf10dce41 Move prisma to dependencies for runtime start 2025-12-07 15:24:30 +01:00
Dries Augustyns 895e0dc1a0 Fix entrypoint to halt if migrations failed 2025-12-07 15:14:56 +01:00
Dries Augustyns fd5acb9b78 Recreate migration 2025-12-07 14:52:20 +01:00
Dries Augustyns c189175658 Selectors with description 2025-12-07 14:20:44 +01:00
Dries Augustyns 683356c17c Update wiki 2025-12-07 13:36:31 +01:00
Dries Augustyns 0003e44db8 Update wiki 2025-12-07 10:25:18 +01:00
Dries Augustyns b2ecf60a13 Improve UI/UX Consistency 2025-12-06 10:06:11 +01:00
Dries Augustyns 903ea73ca2 Improve domain input loading 2025-12-06 09:45:13 +01:00
Dries Augustyns d392d69fd2 Changes to copy on landing page 2025-12-06 09:09:11 +01:00
Dries Augustyns 19258477d2 Changes to copy on landing page 2025-12-06 09:09:07 +01:00
Dries Augustyns e00fc1952b Attempt to reduce image size 2025-12-06 08:57:22 +01:00
Dries Augustyns 1a6fbbdc9a Improvements to package versioning, dependencies, etc 2025-12-06 08:16:47 +01:00
Dries Augustyns caaa58a0ff Upgrade nextjs and React to latest version 2025-12-06 08:02:52 +01:00
Dries Augustyns eac3815668 Update migration to include GIN indexes 2025-12-05 15:42:15 +01:00
Dries Augustyns 18b7d7fe98 Add billing limits display in currency 2025-12-05 12:28:23 +01:00
Dries Augustyns 9d7e8a1d1a Added correct Stripe endpoints for billing 2025-12-05 11:12:26 +01:00
Dries Augustyns ce552682c0 Upgrade Stripe to latest version 2025-12-05 09:25:00 +01:00
Dries Augustyns 7d90bef8f4 Update project name 2025-12-05 09:09:44 +01:00
Dries Augustyns f88e6f439d Update workflow with native runners 2025-12-05 08:19:04 +01:00
Dries Augustyns 04a64ef83a Added display of credits 2025-12-05 07:59:47 +01:00
Dries Augustyns 8c348d3580 Fix event propagation in sidebar 2025-12-04 21:20:40 +01:00
Dries Augustyns b4b7cc78f1 Fix linting error 2025-12-04 20:17:56 +01:00
Dries Augustyns ec70d50b39 Update billing limit tests 2025-12-04 20:10:29 +01:00
Dries Augustyns 2b65940e73 Only send bounce event every 20 emails 2025-12-04 19:44:20 +01:00
Dries Augustyns af54ab4c4e Update to create page 2025-12-04 19:36:04 +01:00
Dries Augustyns 5fc5bc68fd Add billing limit 2025-12-04 19:23:04 +01:00
Dries Augustyns 6cdbc43c8e Add ntfy.sh 2025-12-04 18:10:21 +01:00
Dries Augustyns c114feb32e Add ntfy.sh 2025-12-04 17:34:24 +01:00
Dries Augustyns 726493a1ed Add membership page 2025-12-04 15:29:30 +01:00
Dries Augustyns d08471edc2 Fix rounded corner overflow 2025-12-04 13:59:26 +01:00
Dries Augustyns a7c214f9ea Fix template page width 2025-12-04 13:57:42 +01:00
Dries Augustyns 73918f5211 Fix test cases 2025-12-04 13:17:07 +01:00
Dries Augustyns 04634a447b Fix linting errors 2025-12-04 12:46:39 +01:00
Dries Augustyns 73a1cdfefa Fix lint errors 2025-12-04 11:50:52 +01:00
Dries Augustyns fde00ebda3 Fix build errors 2025-12-04 11:42:16 +01:00
Dries Augustyns 6fe3779fcb Responsive design 2025-12-04 11:23:55 +01:00
Dries Augustyns 736672007b Refactor components to use memoization and callbacks for performance improvements 2025-12-04 10:14:18 +01:00
Dries Augustyns 3b73273629 Remove gray colours 2025-12-04 09:45:03 +01:00
Dries Augustyns 40e35735dc Add improved full screen loader 2025-12-04 09:27:14 +01:00
Dries Augustyns e4a43dfb57 Import error handling and subscribe state 2025-12-03 21:52:17 +01:00
Dries Augustyns a28be9adc9 Reset and deletion of project 2025-12-03 21:30:54 +01:00
Dries Augustyns 382e97e5b2 Refactorings, error handling and logout 2025-12-03 20:39:11 +01:00
Dries Augustyns 113b94cf2c Add activity feed to contact page 2025-12-03 19:06:18 +01:00
Dries Augustyns c1f823e2f1 Improvement to workflow creation 2025-12-03 17:36:05 +01:00
Dries Augustyns 9fadd01a6e Fix for delay actions in workflows 2025-12-03 16:22:59 +01:00
Dries Augustyns b3e6d03962 Add shared replacement method 2025-12-03 13:57:15 +01:00
Dries Augustyns 8defb9f8ab Add contact data 2025-12-03 13:28:41 +01:00
Dries Augustyns d96bce75d7 Update useContactFields to ignore type 2025-12-03 10:15:06 +01:00
Dries Augustyns de34b8c76b Add coverage to data management tab 2025-12-03 08:21:54 +01:00
Dries Augustyns e506659736 Limit to 25 emails per second 2025-12-02 13:11:27 +01:00
Dries Augustyns 2745c1bf5d Added extra analytics 2025-12-02 11:10:30 +01:00
Dries Augustyns 2ef463ce5e Added events for subscribe and unsubscribe 2025-12-02 09:56:27 +01:00
Dries Augustyns 2e6b485532 Data management tab 2025-12-02 09:46:12 +01:00
Dries Augustyns 61cb93d04c Fix linting errors 2025-12-02 09:06:40 +01:00
Dries Augustyns fe2e038037 Add better segmenting 2025-12-02 09:03:05 +01:00
Dries Augustyns b521a405bb Add events to workflows 2025-12-01 19:08:02 +01:00
Dries Augustyns b1f0043940 Add events to workflows 2025-12-01 18:37:53 +01:00
Dries Augustyns 5158756650 Automatic SNS verification 2025-12-01 16:47:06 +01:00
Dries Augustyns 62d7a61834 Automatic SNS verification 2025-12-01 16:27:19 +01:00
Dries Augustyns 54fde9675c Update tests to handle domain verification 2025-12-01 15:36:31 +01:00
Dries Augustyns e893960a85 Verify email domain before sending emails 2025-12-01 15:27:28 +01:00
Dries Augustyns 361ff6fede Added async validation 2025-12-01 15:21:12 +01:00
Dries Augustyns 1f3978e89c Added support for to and from name 2025-12-01 14:06:09 +01:00
Dries Augustyns 2dd6af8ff7 tests: Added source type to tests 2025-12-01 10:15:35 +01:00
Dries Augustyns 84caef5c45 Initial push of Plunk Next 2025-12-01 10:04:22 +01:00
Dries Augustyns 5b430fba0d Initial push of Plunk Next 2025-12-01 10:00:25 +01:00
Dries Augustyns ff1876d580 Initial push of Plunk Next 2025-12-01 09:56:56 +01:00
650 changed files with 116610 additions and 29590 deletions
+67 -5
View File
@@ -1,6 +1,68 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
.next
.turbo
out
# Development
.env
.next/
.github/
dist/
assets/
node_modules/
.env*.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage
.nyc_output
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/*.spec.ts
**/*.spec.tsx
**/*.spec.js
**/*.spec.jsx
test/
vitest.config.ts
# IDE
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
# Git
.git
.gitignore
.gitattributes
# CI/CD
.github
.gitlab-ci.yml
# Documentation (not needed in image)
*.md
!README.md
# Docker
Dockerfile*
docker
!docker/nginx
!docker/*.sh
.dockerignore
# Misc
.cursor
.eslintcache
.cache
tmp
temp
+151
View File
@@ -0,0 +1,151 @@
# ========================================
# Plunk Self-Hosting Configuration
# ========================================
# ========================================
# REQUIRED: Security & Database
# ========================================
DB_PASSWORD=changeme123
JWT_SECRET=
# ========================================
# REQUIRED: Domains
# ========================================
# Replace example.com with your domain
# Or use *.localhost for local testing
API_DOMAIN=api.example.com
DASHBOARD_DOMAIN=app.example.com
LANDING_DOMAIN=www.example.com
WIKI_DOMAIN=docs.example.com
# For local development: URIs for running services locally
# These are used by the applications at runtime
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# Use placeholder URLs that will be replaced at Docker container runtime
NEXT_PUBLIC_API_URI=https://api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://docs.useplunk.com
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
# This affects how application URIs are auto-generated from domain names
USE_HTTPS=false
# ========================================
# REQUIRED: AWS SES (Email Sending)
# ========================================
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=
AWS_SES_SECRET_ACCESS_KEY=
# Configuration sets for email tracking
# SES_CONFIGURATION_SET: Default configuration with open/click tracking enabled
SES_CONFIGURATION_SET=plunk-configuration-set
# SES_CONFIGURATION_SET_NO_TRACKING: Optional configuration without tracking
# If not set, the tracking toggle will be hidden in project settings
# When set, projects can choose to disable email tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
# ========================================
# OPTIONAL: OAuth Login
# ========================================
GITHUB_OAUTH_CLIENT=
GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
# ========================================
# OPTIONAL: Stripe Billing
# ========================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING=
STRIPE_PRICE_EMAIL_USAGE=
STRIPE_METER_EVENT_NAME=emails
# ========================================
# OPTIONAL: File Storage (Minio)
# ========================================
# Minio is included by default in Docker Compose
# These credentials match the Minio service configuration
# Leave defaults unless you're using external storage
MINIO_ROOT_USER=plunk
MINIO_ROOT_PASSWORD=plunkminiopass
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# S3-compatible storage configuration
# For self-hosted: uses internal Minio service (defaults work out of the box)
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# ========================================
# OPTIONAL: Notifications (ntfy.sh)
# ========================================
# Plunk includes a self-hosted ntfy.sh server for system notifications
# You can access the ntfy web UI at http://localhost:8080 (or your configured NTFY_PORT)
# Subscribe to the topic 'plunk-notifications' to receive notifications
# Port for ntfy web UI and API (default: 8080)
NTFY_PORT=8080
# Ntfy topic URL - Uses internal ntfy service by default
# To use external ntfy.sh or your own server, change this URL
# Examples:
# - Self-hosted (default): http://ntfy/plunk-notifications
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
# - Custom server: https://your-ntfy-server.com/your-topic
NTFY_URL=http://ntfy/plunk-notifications
# ========================================
# OPTIONAL: SMTP Server
# ========================================
# The SMTP relay server allows sending emails via SMTP protocol
# TLS certificates can be mounted via:
# 1. Traefik acme.json (requires SMTP_DOMAIN to select the right cert)
# 2. PEM files (privkey.pem and fullchain.pem)
# SMTP domain - Required if using Traefik acme.json with multiple certificates
# Optional if using PEM files
SMTP_DOMAIN=smtp.example.com
# SMTP Ports (defaults work for most setups)
# PORT_SECURE=465 # SMTPS (implicit TLS)
# PORT_SUBMISSION=587 # SMTP Submission (STARTTLS)
# Maximum recipients per email (default: 5)
# MAX_RECIPIENTS=5
# ========================================
# OPTIONAL: Platform emails
# ========================================
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
# These emails include system notifications, for example when your project hits billing limits
# PLUNK_API_KEY=
# PLUNK_FROM_ADDRESS=
# ========================================
# OPTIONAL: Security Settings
# ========================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# When enabled (default), projects exceeding security limits will be automatically suspended
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
# Recommended for self-hosters: false (manage project status manually)
# Default: true
# AUTO_PROJECT_DISABLE=false
# ========================================
# ADVANCED (rarely needed)
# ========================================
# NGINX_PORT=80
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
ecmaVersion: 2024,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'next/core-web-vitals',
'prettier',
],
plugins: ['@typescript-eslint', 'react', 'jsx-a11y', 'import'],
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_'},
],
'@typescript-eslint/consistent-type-imports': ['warn', {prefer: 'type-imports'}],
'import/order': [
'warn',
{
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling'],
'newlines-between': 'always',
'alphabetize': {order: 'asc'},
},
],
},
settings: {
react: {
version: 'detect',
},
},
ignorePatterns: ['node_modules/', 'dist/', '.next/', '*.config.js', '*.config.ts'],
};
+45
View File
@@ -0,0 +1,45 @@
## Description
<!-- Describe your changes in detail -->
## Type of Change
<!-- Mark the appropriate option with an 'x' -->
- [ ] `feat:` New feature (MINOR version bump)
- [ ] `fix:` Bug fix (PATCH version bump)
- [ ] `feat!:` Breaking change - new feature (MAJOR version bump)
- [ ] `fix!:` Breaking change - bug fix (MAJOR version bump)
- [ ] `docs:` Documentation update (no version bump)
- [ ] `chore:` Maintenance/dependencies (no version bump)
- [ ] `refactor:` Code refactoring (no version bump)
- [ ] `test:` Adding tests (no version bump)
- [ ] `perf:` Performance improvement (PATCH version bump)
## PR Title Format
<!--
Your PR title should follow conventional commits format:
✅ feat: add email template editor
✅ fix: resolve memory leak in worker
✅ feat!: redesign API authentication
❌ Added new feature (missing type prefix)
See VERSIONING.md for more details
-->
## Testing
<!-- Describe how you tested your changes -->
## Checklist
- [ ] PR title follows conventional commits format
- [ ] Code builds successfully
- [ ] Tests pass locally
- [ ] Documentation updated (if needed)
## Related Issues
<!-- Link any related issues here -->
Closes #
+197
View File
@@ -0,0 +1,197 @@
name: CI
on:
push:
branches:
- next
pull_request:
branches:
- next
jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
# Service containers for database, Redis, and Minio
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: plunk_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
minio:
image: minio/minio:edge-cicd
env:
MINIO_ROOT_USER: plunk
MINIO_ROOT_PASSWORD: plunkminiopass
ports:
- 9000:9000
options: >-
--health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup environment variables
run: |
cat > .env << EOF
NODE_ENV=test
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
# Redis
REDIS_URL=redis://localhost:6379
# Security
JWT_SECRET=test-jwt-secret-for-ci-only
# S3 (Minio)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Application URLs (not needed for tests but required by schema)
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# AWS SES (mock values for tests)
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=mock
AWS_SES_SECRET_ACCESS_KEY=mock
SES_CONFIGURATION_SET=test
SES_CONFIGURATION_SET_NO_TRACKING=test-no-tracking
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
- name: Run database migrations
run: yarn workspace @plunk/db migrate:prod
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
- name: Create MinIO bucket
run: |
docker run --rm --network host \
--entrypoint /bin/sh minio/mc:latest \
-c "mc alias set local http://localhost:9000 plunk plunkminiopass && \
mc mb local/uploads --ignore-existing"
- name: Run tests
run: yarn test:run
env:
NODE_ENV: test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
coverage/
**/*.test.ts.log
retention-days: 7
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup minimal env (for build)
run: |
cat > .env << EOF
NODE_ENV=development
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
REDIS_URL=redis://localhost:6379
JWT_SECRET=test
# S3 (minimal - not needed but schema may require)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=mock
S3_ACCESS_KEY_SECRET=mock
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Application URLs
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# AWS SES (mock)
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=mock
AWS_SES_SECRET_ACCESS_KEY=mock
SES_CONFIGURATION_SET=test
SES_CONFIGURATION_SET_NO_TRACKING=test
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
- name: Run linter
run: yarn lint
- name: Type check
run: yarn build --filter="api" --filter="web" --filter="landing" --filter="wiki" || true
-32
View File
@@ -1,32 +0,0 @@
name: Build and Push Docker image (Canary)
on:
push:
branches:
- canary
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
driaug/plunk:canary
platforms: linux/amd64,linux/arm64
-37
View File
@@ -1,37 +0,0 @@
name: Build and Push Docker image (Production)
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Get version from package.json
id: get_version
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
driaug/plunk:${{ env.VERSION }}
driaug/plunk:latest
platforms: linux/amd64,linux/arm64
+225
View File
@@ -0,0 +1,225 @@
name: Docker Build and Publish
on:
push:
branches:
- next
jobs:
# Prepare metadata and tags for all builds
prepare:
runs-on: ubuntu-latest
outputs:
is_release: ${{ steps.check-release.outputs.is_release }}
release_tag: ${{ steps.check-release.outputs.release_tag }}
tags: ${{ steps.tags.outputs.tags }}
image: ${{ steps.tags.outputs.image }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if this commit is a release
id: check-release
run: |
git fetch --tags
# Check if this is a release-please commit
if git log -1 --pretty=%B | grep -q "release-please--branches--next"; then
echo "This is a release commit, waiting for tag..."
# Wait up to 60 seconds for release-please to create the tag
for i in {1..12}; do
sleep 5
git fetch --tags
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
if [[ -n "$TAG" ]]; then
echo "is_release=true" >> $GITHUB_OUTPUT
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
echo "Found release tag: $TAG"
exit 0
fi
echo "Waiting for tag... ($i/12)"
done
echo "ERROR: Release commit but no tag found after 60s"
exit 1
else
# Regular commit, check for tag immediately
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
if [[ -n "$TAG" ]]; then
echo "is_release=true" >> $GITHUB_OUTPUT
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
echo "This is a release: $TAG"
else
echo "is_release=false" >> $GITHUB_OUTPUT
echo "This is a regular commit"
fi
fi
- name: Compute tags
id: tags
run: |
IMAGE="ghcr.io/${{ github.repository }}"
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
# This is a release: use semver tags + latest
TAG="${{ steps.check-release.outputs.release_tag }}"
VERSION="${TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
echo "Building RELEASE with tags: $TAGS"
else
# Regular commit: use SHA tags only (no latest)
SHORT_SHA="${GITHUB_SHA:0:7}"
TAGS="sha-${SHORT_SHA}"
echo "Building COMMIT with tags: $TAGS"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "image=$IMAGE" >> $GITHUB_OUTPUT
# Build natively on each platform (faster, no QEMU emulation)
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-turbo-
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
docker system prune -af --volumes
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.image }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: true
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ needs.prepare.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
build-args: |
BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VERSION=${{ github.ref_name }}
REVISION=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Merge platform-specific images into multi-arch manifest
merge:
needs: [ prepare, build ]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
# Convert comma-separated tags to array and create docker tag arguments
TAG_ARGS=""
IFS=',' read -ra TAG_ARRAY <<< "$TAGS"
for tag in "${TAG_ARRAY[@]}"; do
TAG_ARGS="$TAG_ARGS -t ${IMAGE}:${tag}"
done
# Create and push manifest using all digests
docker buildx imagetools create $TAG_ARGS \
$(printf '${{ needs.prepare.outputs.image }}@sha256:%s ' *)
- name: Inspect image
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
FIRST_TAG=$(echo "$TAGS" | cut -d',' -f1)
docker buildx imagetools inspect ${IMAGE}:${FIRST_TAG}
- name: Summary
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
echo "## Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.prepare.outputs.is_release }}" == "true" ]]; then
echo "**Type:** Release (${{ needs.prepare.outputs.release_tag }})" >> $GITHUB_STEP_SUMMARY
else
echo "**Type:** Commit (sha-${GITHUB_SHA:0:7})" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$TAGS" | tr ',' '\n' | sed "s|^|${IMAGE}:|" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
+25
View File
@@ -0,0 +1,25 @@
name: Release Please
on:
push:
branches:
- next
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Release Please
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
target-branch: next
+47 -8
View File
@@ -1,12 +1,51 @@
# Dependencies
node_modules
dist
.idea
.vscode
*.log
.pnp
.pnp.js
# Local env files
.env
.tscache
.next
.out
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
.source/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
.yarn/install-state.gz
*.pem
.idea/
.vscode/
robots.txt
*sitemap*.xml
.contentlayer
.content-collections
.source
install-state.gz
tsconfig.tsbuildinfo
next-env.d.ts
+10
View File
@@ -0,0 +1,10 @@
{
"printWidth": 120,
"quoteProps": "consistent",
"arrowParens": "avoid",
"singleQuote": true,
"bracketSpacing": false,
"useTabs": false,
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.3.0"
}
-894
View File
File diff suppressed because one or more lines are too long
+948
View File
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -1,3 +1,9 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.3.0.cjs
# Support multiple architectures - download pre-built binaries instead of compiling
supportedArchitectures:
os: [ "linux", "darwin" ]
cpu: [ "x64", "arm64" ]
libc: [ "glibc" ]
yarnPath: .yarn/releases/yarn-4.9.1.cjs
+143
View File
@@ -0,0 +1,143 @@
# Changelog
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
### Features
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
### Bug Fixes
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
### Documentation
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
### Features
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
### Bug Fixes
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
### Bug Fixes
* Add additional verification in Oauth controllers ([53ecda9](https://github.com/useplunk/plunk/commit/53ecda9f7a34111785ac3ea3af18cb33460a44af))
* add clear cache button on full-screen loader ([60804c1](https://github.com/useplunk/plunk/commit/60804c12b0b4c4c3ef97e730e4857d41fa1fd021))
* Dedicated token name for next version ([fc535a5](https://github.com/useplunk/plunk/commit/fc535a558fab28045dae9ce978f483e58c72a24f))
* only mark releases as latest ([9d8b4a5](https://github.com/useplunk/plunk/commit/9d8b4a59fbd42420bb595d2a44df93a29214121a))
### Documentation
* dynamic link to Docker Compose for self-hosting ([90f8170](https://github.com/useplunk/plunk/commit/90f8170efbad0cec981a24e7831840aac65d8d70))
* Update AWS setup docs ([f7ac25f](https://github.com/useplunk/plunk/commit/f7ac25f91f4e1dcce301fb325801a67f2a9dee07))
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08)
### Features
* Added ability to overwrite sender mail and name for templates and campaigns ([f1d4d50](https://github.com/useplunk/plunk/commit/f1d4d5073ccd7ddceabfdbd268c5ff142480fb75))
* basic health-checks ([2e69173](https://github.com/useplunk/plunk/commit/2e69173d1378bec8296ce556499f71990ee92375))
* **dashboard:** add status filter for contacts ([45697cf](https://github.com/useplunk/plunk/commit/45697cf6b122266ec3ab35e51e064db48517448d))
* Email attachment support ([0e718d4](https://github.com/useplunk/plunk/commit/0e718d4dba1c8a258eb949cc0590ee14046c080a))
* Increase pagination limits to improve data display and update version to 1.2.1 ([4fe90ad](https://github.com/useplunk/plunk/commit/4fe90adf1e97c85afddeae0ad9e1ecc11dd0609b))
* Optimize data retrieval and add new indexes for performance improvements ([51b40cc](https://github.com/useplunk/plunk/commit/51b40cc06962ad98333a50ac7b4dbd82d8a3fe10))
### Bug Fixes
* Add fixed yarn version ([daa8285](https://github.com/useplunk/plunk/commit/daa8285af59c54c302b805e1d5afa0106567bbf4))
* Add fixed yarn version to Docker build ([ff5c6af](https://github.com/useplunk/plunk/commit/ff5c6af9d730a80d037a84454074dedda5709862))
* Add ref ([4c25a28](https://github.com/useplunk/plunk/commit/4c25a28d69f7f3719b1180924ef1c26958757ba5))
* Add target ([8ca5aea](https://github.com/useplunk/plunk/commit/8ca5aea0ea34f530595c7ec4e1f9b7514ba4e1a9))
* Added Migration ([e2d0d0e](https://github.com/useplunk/plunk/commit/e2d0d0eb0d2989e2668506c48fe55c0e41b29da3))
* Added try/catch to CRON ([c96a007](https://github.com/useplunk/plunk/commit/c96a0074f87f084801289cc9852f27221e4720e9))
* Check if APP_URI has https ([bad18ae](https://github.com/useplunk/plunk/commit/bad18ae559d582181d8c298f3c537c50b4b08851))
* clear Redis key on event deletion ([0ef39ce](https://github.com/useplunk/plunk/commit/0ef39ce3d2ad90be8e83ab97d5076f90642f3cc6))
* Correctly duplicate from ([111e054](https://github.com/useplunk/plunk/commit/111e054fe88997201ebf012602223c92f65d21a6))
* Force node-20 ([0b79bef](https://github.com/useplunk/plunk/commit/0b79bef2cb79cd3f8073884c9b84df733a696bad))
* incorrect domain verification records ([4bb38b9](https://github.com/useplunk/plunk/commit/4bb38b9f43cf495cf08ec6d95466e1b8cea409ce))
* incorrect schema in dashboard ([d69a57d](https://github.com/useplunk/plunk/commit/d69a57d71150e52e12cd1ed475dd16353d5ff9c1))
* move updating the contact to contacts controller ([7c8d3bb](https://github.com/useplunk/plunk/commit/7c8d3bb050fb0c35e0296eeb71d516f0c37329a2))
* Proper reset of contact ([7879e66](https://github.com/useplunk/plunk/commit/7879e6631e8a06020753d7a78583d4a1404b7684))
* refetch contact after awaiting triggers ([7202606](https://github.com/useplunk/plunk/commit/7202606aef0081a1e68f91df3b2e94e67e44ca39))
* refetch contact after awaiting triggers ([3f70be9](https://github.com/useplunk/plunk/commit/3f70be95dde80c04611089f66b1fcaa8eb30655e))
* Remove openssl install ([605a5a0](https://github.com/useplunk/plunk/commit/605a5a0abfbf0d11076440afc07d2f9b2d1ee071))
* return updated contact info ([115ac5f](https://github.com/useplunk/plunk/commit/115ac5fe6c87181fe33de367d88d01ef872ed2af))
* server crash in self-hosted instances ([ccb832d](https://github.com/useplunk/plunk/commit/ccb832d095de7d7b65ee07743ba8cf8025b86948))
* Show project `from` and `name` as backup ([d4bdcbf](https://github.com/useplunk/plunk/commit/d4bdcbf78ecfe7913128f8c5c90e36ee60eb6dca))
* Unlink domain properly executes ([f58149a](https://github.com/useplunk/plunk/commit/f58149a1b168cf931c2a2768345f2a8213fe27a1))
* Update yarn.lock ([ee1651e](https://github.com/useplunk/plunk/commit/ee1651e3902d879047082864a459ff63f7bee57d))
* Version ([5ca4567](https://github.com/useplunk/plunk/commit/5ca45677ffa181b9d288e42f69dc90a2498342af))
* Version ([3a5d05a](https://github.com/useplunk/plunk/commit/3a5d05a65ba38d4abe5f70be4fe1bd195290e055))
* Version ([42e865c](https://github.com/useplunk/plunk/commit/42e865ccbab7799c535a3c18ade4caddffd88f72))
* Version ([b742ab1](https://github.com/useplunk/plunk/commit/b742ab10eb34c52fbd9b9af32e36e3784c2d6fc8))
### Code Refactoring
* remove recipients from campaign data structure ([c40189e](https://github.com/useplunk/plunk/commit/c40189ebbdb80c7e24881191cb1b750d9faffbc1))
+158
View File
@@ -0,0 +1,158 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Plunk is a Turborepo monorepo containing multiple applications and shared packages for a platform service. The project
uses Yarn workspaces with Node.js 20+ requirement.
## Scale & Performance Requirements
**CRITICAL**: This service operates at high scale with a large amount of contacts being added every day. All code
changes must consider:
- **Database Performance**: Queries must be optimized for large datasets (1M+ rows). Avoid N+1 queries, use proper
indexes, and prefer cursor-based pagination over offset-based.
- **Memory Efficiency**: Never load large datasets into memory. Always use streaming or batch processing with reasonable
limits.
- **Asynchronous Operations**: Heavy computations (counts, aggregations, bulk updates) should be offloaded to background
jobs via BullMQ, not executed synchronously in API requests.
- **Caching Strategy**: Frequently accessed computed values should be cached or stored as materialized data to avoid
repeated expensive queries.
- **Query Optimization**: Be mindful of JSON field queries (Contact.data) - these require GIN indexes. Test query plans
with EXPLAIN ANALYZE.
- **API Response Times**: Target < 200ms for read operations, < 500ms for write operations. Use timeouts and circuit
breakers.
When implementing features that query or process contacts, segments, or campaigns:
1. Always consider performance with millions of contacts
2. Use pagination with reasonable defaults (20-100 items)
3. Implement background jobs for bulk operations
4. Add database indexes for new query patterns
5. Cache computed values that don't need real-time accuracy
## Development Commands
### Environment Setup
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, Minio, and Browserless via Docker Compose
- **Build shared packages**: `yarn build --filter="@plunk/shared"` - Required before running apps
### Development
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
- **Build all**: `yarn build`
- **Lint all**: `yarn lint`
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
**Note**: The API's `dev` script automatically runs both the server and worker process using `concurrently`. If you need
to run them separately (e.g., for debugging), use `dev:server` and `dev:worker` individually.
### Database (Prisma)
- **Generate client**: `yarn workspace @plunk/db db:generate`
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
- **Deploy migrations (prod)**: `yarn workspace @plunk/db migrate:prod`
## Architecture
### Applications (`apps/`)
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
- HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
### Background Job Architecture
The API uses BullMQ (backed by Redis) for asynchronous job processing:
- **API Server** creates jobs and adds them to queues (email, campaign, workflow)
- **Worker Process** (`apps/api/src/jobs/worker.ts`) consumes jobs from queues
- Jobs are processed with retry logic, rate limiting, and concurrency control
- Worker runs separately for scalability and fault isolation (can scale workers independently)
### Shared Packages (`packages/`)
- **@plunk/db**: Prisma schema and client
- **@plunk/ui**: ShadCN-based UI library with Radix UI + Tailwind
- **@plunk/shared**: Common utilities and business logic
- **@plunk/types**: TypeScript type definitions
- **@plunk/email**: React-email templates
- **@plunk/notifications**: Notification system
## Key Technologies
- **Frontend**: React 19, Next.js 15.3, Tailwind CSS, Framer Motion
- **Backend**: Express.js, Prisma, Redis (ioredis), Stripe
- **UI Library**: Radix UI primitives, ShadCN components
- **Authentication**: JWT with bcrypt
## Code Standards
### Import Organization
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
between groups.
### TypeScript
- Consistent type imports preferred: `import type { ... }`
- Unused vars allowed with `_` prefix
- Strict type checking enabled across all packages
### Component Structure
- UI components in `packages/ui/src/components/`
- App-specific components in `apps/<app>/src/components/`
- Atomic design pattern: atoms → molecules hierarchy
## Environment Variables
**Configuration File Setup:**
- **Development**: Copy `.env.example` to `.env` at the repository root and fill in your values
- **All apps** (API, web, landing, wiki) load environment variables from the root `.env` file
- **Production**: Environment variables are injected by Docker/orchestration systems (no .env file needed)
Required for builds and deployment (see turbo.json and .env.example):
**Build Time:**
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL` (for Prisma client generation)
- Standard: `NODE_ENV`
**Runtime:**
- Security: `JWT_SECRET`
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL`
- Infrastructure: `REDIS_URL`
- **Application URLs** (injected at runtime into Next.js apps): `API_URI`, `DASHBOARD_URI`, `LANDING_URI`, `WIKI_URI` (
optional)
- S3-compatible Storage (Minio): `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_ACCESS_KEY_SECRET`, `S3_BUCKET`,
`S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`
- AWS SES: `AWS_SES_REGION`, `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `SES_CONFIGURATION_SET`,
`SES_CONFIGURATION_SET_NO_TRACKING`
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
`STRIPE_METER_EVENT_NAME`
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
**Important Notes:**
- **Development**: All environment variables are loaded from the root `.env` file (monorepo-wide)
- **Production**: The application URLs (`API_URI`, `DASHBOARD_URI`, etc.) are injected at Docker container startup. This
allows the same Docker image to be used across different environments by simply changing environment variables at
runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access
+131 -16
View File
@@ -1,29 +1,144 @@
# Contributing
# Contributing to Plunk
You can greatly support Plunk by contributing to this repository.
Thank you for your interest in contributing to Plunk! This guide will help you get started with development.
Support can be asked in the `#contributions` channel of the [Plunk Discord server](https://useplunk.com/discord)
## Architecture
### 1. Requirements
Plunk V2 is built as a modern Turborepo monorepo with the following structure:
- Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system.
### Applications (`apps/`)
### 2. Install dependencies
- **api**: Express.js API server with background worker process (BullMQ)
- **web**: Next.js dashboard application (app.useplunk.com)
- **landing**: Next.js marketing site (www.useplunk.com)
- **wiki**: Next.js documentation site (docs.useplunk.com)
- Run `yarn install` to install the dependencies.
### Shared Packages (`packages/`)
### 3. Set your environment variables
- **@plunk/db**: Prisma database schema and client
- **@plunk/ui**: Shared UI components (ShadCN + Radix UI)
- **@plunk/shared**: Common utilities and business logic
- **@plunk/types**: TypeScript type definitions
- **@plunk/email**: React Email templates
- Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
- Set AWS credentials in the `api` `.env` file.
### Technology Stack
### 4. Start resources
- **Frontend**: React 19, Next.js 15, Tailwind CSS, Framer Motion
- **Backend**: Express.js, TypeScript (ESM), Prisma ORM
- **Database**: PostgreSQL with connection pooling
- **Cache/Queue**: Redis, BullMQ for background jobs
- **Email Delivery**: AWS SES with bounce/complaint handling
- **Storage**: S3-compatible (MinIO, AWS S3)
- **Payments**: Stripe with usage-based billing
- Run `yarn services:up` to start a local database and a local redis server.
- Run `yarn migrate` to apply the migrations to the database.
- Run `yarn build:shared` to build the shared package.
## Development Setup
For local development without Docker:
- Run `yarn dev:api` to start the API server.
- Run `yarn dev:dashboard` to start the dashboard server.
### Prerequisites
- Node.js 20+
- Yarn 4.9+
- Docker & Docker Compose (for services)
### Quick Start
```bash
# Clone and install
git clone <repo-url>
cd app
yarn install
# Start infrastructure services (PostgreSQL, Redis, MinIO)
yarn services:up
# Set up environment variables
cp .env.example .env
# Edit .env with your configuration
# Run database migrations
yarn workspace @plunk/db migrate:dev
# Start all development servers
yarn dev
```
**Note**: The `dev` command starts the API server, worker process, and web apps. For production-like setup or debugging,
you can run components separately:
```bash
# Terminal 1: API Server
yarn workspace api dev:server
# Terminal 2: Background Worker (required for emails)
yarn workspace api dev:worker
# Terminal 3: Web Dashboard
yarn workspace web dev
```
## Development Commands
### Environment Setup
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, MinIO via Docker Compose
- **Stop services**: `yarn services:down` - Stops all infrastructure services
### Development
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
- **Build all**: `yarn build`
- **Lint all**: `yarn lint`
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
### Database (Prisma)
- **Generate client**: `yarn workspace @plunk/db db:generate`
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
## Code Standards
### Import Organization
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
between groups.
### TypeScript
- Use TypeScript for all new code
- Prefer type imports: `import type { ... }`
- Enable strict type checking
### Component Structure
- UI components go in `packages/ui/src/components/`
- App-specific components in `apps/<app>/src/components/`
- Follow atomic design pattern where applicable
## Making Changes
1. **Fork the repository** and create a new branch from `main`
2. **Make your changes** following the code standards above
3. **Test your changes** thoroughly
4. **Commit your changes** with clear, descriptive commit messages
5. **Push to your fork** and submit a pull request
## Pull Request Guidelines
- Provide a clear description of the changes
- Reference any related issues
- Ensure all tests pass and linting is clean
- Keep PRs focused on a single feature or fix
## Need Help?
- Check the [documentation](https://docs.useplunk.com)
- Open an issue for bugs or feature requests
- Join our community discussions
## License
By contributing to Plunk, you agree that your contributions will be licensed under the AGPL-3.0 License.
+348 -23
View File
@@ -1,35 +1,360 @@
# Base Stage
FROM node:20-alpine3.20 AS base
# Multi-stage Dockerfile for Plunk
# Creates a single image containing all applications (API, Worker, Web, Landing, Wiki)
# Use SERVICE environment variable to specify which service to run
# ============================================
# Stage 1: Dependencies (All dependencies for building)
# ============================================
# Use build platform (AMD64) to install dependencies, avoiding QEMU issues
FROM --platform=$BUILDPLATFORM node:20-slim AS deps
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy Yarn configuration and release
COPY .yarnrc.yml ./
COPY .yarn/releases ./.yarn/releases
# Copy package files for dependency installation
COPY package.json yarn.lock ./
# Copy workspace package.json files
COPY apps/api/package.json ./apps/api/
COPY apps/smtp/package.json ./apps/smtp/
COPY apps/web/package.json ./apps/web/
COPY apps/landing/package.json ./apps/landing/
COPY apps/wiki/package.json ./apps/wiki/
COPY packages/db/package.json ./packages/db/
COPY packages/ui/package.json ./packages/ui/
COPY packages/shared/package.json ./packages/shared/
COPY packages/types/package.json ./packages/types/
COPY packages/email/package.json ./packages/email/
COPY packages/typescript-config/package.json ./packages/typescript-config/
COPY packages/eslint-config/package.json ./packages/eslint-config/
# Copy wiki source files for postinstall script (fumadocs-mdx)
COPY apps/wiki/content ./apps/wiki/content
COPY apps/wiki/lib ./apps/wiki/lib
COPY apps/wiki/source.config.ts ./apps/wiki/source.config.ts
COPY apps/wiki/mdx-components.tsx ./apps/wiki/mdx-components.tsx
COPY apps/wiki/next.config.mjs ./apps/wiki/next.config.mjs
COPY apps/wiki/tsconfig.json ./apps/wiki/tsconfig.json
# Install dependencies (runs on build platform, fetches binaries for target platform)
# Use cache mounts for Yarn cache to speed up dependency installation
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" && \
yarn install --immutable
# ============================================
# Stage 1b: Production Dependencies for API/SMTP
# ============================================
# Install only production dependencies needed for API and SMTP services
FROM --platform=$BUILDPLATFORM node:20-slim AS prod-deps
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy Yarn configuration
COPY .yarnrc.yml ./
COPY .yarn/releases ./.yarn/releases
# Copy all package.json files (needed for workspace resolution)
COPY package.json yarn.lock ./
COPY apps/api/package.json ./apps/api/
COPY apps/smtp/package.json ./apps/smtp/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
COPY packages/types/package.json ./packages/types/
COPY packages/email/package.json ./packages/email/
# Install ONLY production dependencies for api, smtp, and their workspace dependencies
# This excludes devDependencies and unneeded workspaces (web, landing, wiki, ui)
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
echo "Installing production dependencies for API/SMTP on $BUILDPLATFORM for $TARGETPLATFORM" && \
yarn workspaces focus api smtp --production
# ============================================
# Stage 2: Builder
# ============================================
# Builder runs on target platform to generate platform-specific artifacts
FROM node:20-slim AS builder
ARG TARGETPLATFORM
# Build-time arguments for URL configuration
# These are only used during the build process (for wiki OpenAPI generation and static assets)
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
ARG API_URI=https://api.useplunk.com
ARG DASHBOARD_URI=https://app.useplunk.com
ARG LANDING_URI=https://www.useplunk.com
ARG WIKI_URI=https://docs.useplunk.com
WORKDIR /app
# Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.yarn ./.yarn
COPY --from=deps /app/.yarnrc.yml ./
COPY --from=deps /app/package.json ./
COPY --from=deps /app/yarn.lock ./
# ============================================
# OPTIMIZATION: Copy and build in layers for better Docker cache utilization
# Docker will only rebuild from the first changed layer, not everything
# ============================================
# Copy root config files needed for Turbo
COPY turbo.json ./
# Copy manifest generation script
COPY docker/generate-url-manifest.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
# Step 1: Copy and build shared packages (these change less frequently)
# Shared packages are dependencies for apps, so build them first
COPY packages ./packages
RUN yarn workspace @plunk/db db:generate
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter="@plunk/*"
# Step 2: Copy and build API (backend services)
COPY apps/api ./apps/api
COPY apps/smtp ./apps/smtp
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=api --filter=smtp
# Step 3: Copy and build Wiki (includes API doc generation)
COPY apps/wiki ./apps/wiki
# Generate OpenAPI spec and docs (URLs will be placeholder values for runtime replacement)
RUN cd apps/wiki && \
node scripts/generate-openapi.js && \
npx tsx scripts/generate-docs.mts && \
npx fumadocs-mdx && \
cd ../..
# Build wiki with placeholder URLs (replaced at container startup)
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=wiki
# Generate sitemap for wiki
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
# Generate URL replacement manifest for wiki (build-time optimization)
RUN generate-url-manifest.sh wiki /app/apps/wiki
# Step 4: Copy and build Web dashboard
COPY apps/web ./apps/web
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=web
# Generate sitemap for web
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
# Generate URL replacement manifest for web (build-time optimization)
RUN generate-url-manifest.sh web /app/apps/web
# Step 5: Copy and build Landing page
COPY apps/landing ./apps/landing
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=landing
# Generate sitemap for landing
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
# Generate URL replacement manifest for landing (build-time optimization)
RUN generate-url-manifest.sh landing /app/apps/landing
# Copy any remaining root files (if needed)
COPY . .
ARG NEXT_PUBLIC_API_URI=PLUNK_API_URI
RUN yarn install --network-timeout 1000000
RUN yarn build:shared
RUN yarn workspace @plunk/api build
RUN yarn workspace @plunk/dashboard build
# Final Stage
FROM node:20-alpine3.20
# Ensure directories exist (create empty ones if build didn't generate them)
RUN mkdir -p \
apps/web/public \
apps/landing/public \
apps/wiki/public \
apps/web/.next/standalone \
apps/landing/.next/standalone \
apps/wiki/.next/standalone
# ============================================
# Stage 3: Production Runtime
# ============================================
FROM node:20-alpine AS runner
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
RUN apk add --no-cache bash nginx
# Install OpenSSL for Prisma, curl for health checks, nginx, and gettext (for envsubst)
RUN apk add --no-cache openssl curl nginx gettext
COPY --from=base /app/packages/api/dist /app/packages/api/
COPY --from=base /app/packages/dashboard/.next /app/packages/dashboard/.next
COPY --from=base /app/packages/dashboard/public /app/packages/dashboard/public
COPY --from=base /app/node_modules /app/node_modules
COPY --from=base /app/packages/shared /app/packages/shared
COPY --from=base /app/prisma /app/prisma
COPY deployment/nginx.conf /etc/nginx/nginx.conf
COPY deployment/entry.sh deployment/replace-variables.sh /app/
# Install PM2 globally for process management
# Use cache mount and specific version to prevent hangs
RUN --mount=type=cache,target=/root/.npm \
npm install -g pm2@5.4.2 --prefer-offline --no-audit
RUN chmod +x /app/entry.sh /app/replace-variables.sh
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 plunk
EXPOSE 3000 4000 5000
# Create nginx directories and set permissions
RUN mkdir -p /var/log/nginx /var/lib/nginx /run/nginx && \
chown -R plunk:nodejs /var/log/nginx /var/lib/nginx /run/nginx /etc/nginx
CMD ["sh", "/app/entry.sh"]
# ============================================
# Copy API and SMTP services with minimal dependencies
# ============================================
# Copy built API and SMTP services
COPY --from=builder --chown=plunk:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/dist ./apps/smtp/dist
# Copy ONLY production dependencies for API/SMTP (excludes dev deps and frontend packages)
COPY --from=prod-deps --chown=plunk:nodejs /app/node_modules ./node_modules
# Copy Prisma client from builder (includes generated client with correct platform binaries)
COPY --from=builder --chown=plunk:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=plunk:nodejs /app/node_modules/@prisma ./node_modules/@prisma
# Copy only the shared packages that are built (not source files)
# These are needed by API/SMTP at runtime
COPY --from=builder --chown=plunk:nodejs /app/packages/db/dist ./packages/db/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/db/package.json ./packages/db/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
# @plunk/types exports source TypeScript files directly (no build output)
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
# Copy Prisma schema (needed for migrations at runtime)
COPY --from=builder --chown=plunk:nodejs /app/packages/db/prisma ./packages/db/prisma
# Copy root package.json and workspace config (needed for yarn workspace commands in entrypoint)
COPY --from=builder --chown=plunk:nodejs /app/package.json ./
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarnrc.yml ./
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarn ./.yarn
COPY --from=prod-deps --chown=plunk:nodejs /app/yarn.lock ./
# Copy API/SMTP package.json files
COPY --from=builder --chown=plunk:nodejs /app/apps/api/package.json ./apps/api/
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp/
# ============================================
# Copy Next.js apps with their standalone builds
# ============================================
# Next.js standalone mode bundles all dependencies internally, so we don't need
# to copy node_modules for these apps - they're completely self-contained
# Web app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
# Landing app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
# Wiki app - standalone build with static assets and OpenAPI spec
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
# Copy full .next directories for the entrypoint script (URL replacement via find command)
# These are much smaller than node_modules and needed for runtime URL replacement
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next ./apps/web/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next ./apps/landing/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next ./apps/wiki/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/openapi.local.json
# ============================================
# Copy runtime configuration
# ============================================
# Copy nginx configuration templates and setup script
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
RUN chmod +x /app/docker/nginx/setup-nginx.sh
# Copy optimized URL replacement script
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
RUN chmod +x /app/docker/replace-urls-optimized.sh
# Copy entrypoint script
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
USER plunk
# Expose nginx port (default 80), SMTP ports (465, 587)
# Port 80 is also used for ACME HTTP-01 challenges
EXPOSE 80 465 587
# Health check through nginx
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Default to running all services via entrypoint
ENV SERVICE=all
ENTRYPOINT ["docker-entrypoint-nginx.sh"]
+25 -24
View File
@@ -7,11 +7,14 @@
</p>
<p align="center">
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
<img src="https://img.shields.io/github/actions/workflow/status/driaug/plunk-whitelabel/docker-build-prod.yml"/>
<img src="https://img.shields.io/docker/pulls/driaug/plunk"/>
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
</p>
## Introduction
@@ -23,36 +26,34 @@ like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun
## Features
- **Transactional Emails**: Send emails straight from your API
- **Automations**: Create automations based on user actions
- **Broadcasts**: Send newsletters and product updates to big audiences
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
## Sponsors
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting via [GitHub Sponsors](https://github.com/sponsors/driaug).
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="https://avatars.githubusercontent.com/u/206509599?s=200&v=4" style="width:80px; height:80px; object-fit:contain;"/><br/>
<a href="https://every.news/?ref=useplunk.com">Everynews</a>
</td>
</tr>
</table>
</div>
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting
via [GitHub Sponsors](https://github.com/sponsors/driaug).
## Self-hosting Plunk
The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image.
You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/).
The easiest way to self-host Plunk is by using the `plunk` Docker image.
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
<a href="https://github.com/useplunk/plunk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
</a>
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
</a>
## License
AGPL-3.0 License - see [LICENSE](LICENSE) for details.
+86
View File
@@ -0,0 +1,86 @@
# ==============================================================================
# Development Environment Configuration
# ==============================================================================
# This is for local development. For production/self-hosting, see .env.self-host.example
# ==============================================================================
# Environment & Security
# ==============================================================================
NODE_ENV=development
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
# ==============================================================================
# Application URLs
# ==============================================================================
# Set to 'true' for HTTPS in production (only used when auto-generating URIs from domains)
USE_HTTPS=false
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
# ==============================================================================
# Plunk API (Optional - for sending emails via Plunk API)
# ==============================================================================
# API key for authenticating with the Plunk API (obtained from dashboard)
PLUNK_API_KEY=
# ==============================================================================
# Database & Redis
# ==============================================================================
REDIS_URL=redis://127.0.0.1:56379
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
# ==============================================================================
# S3-compatible Storage (Minio - for file uploads)
# ==============================================================================
# For local development, make sure Minio is running via docker compose
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# ==============================================================================
# AWS SES (Required - for sending emails)
# ==============================================================================
AWS_SES_REGION=eu-north-1
AWS_SES_ACCESS_KEY_ID=
AWS_SES_SECRET_ACCESS_KEY=
# Configuration sets for email tracking
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
# Email sending rate limit (emails per second)
# If not set, automatically fetches from AWS SES account quota (recommended)
# Set this to override AWS quota (useful for setting lower limits or testing)
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
GITHUB_OAUTH_CLIENT=
GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
# ==============================================================================
# Stripe (Optional - for billing)
# ==============================================================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
# ==============================================================================
# Security (Optional)
# ==============================================================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['node_modules/**', 'dist/**', '.turbo/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
+57
View File
@@ -0,0 +1,57 @@
{
"name": "api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -n \"api,worker\" -c \"blue,magenta\" \"yarn dev:server\" \"yarn dev:worker\"",
"dev:server": "tsx watch src/app.ts",
"dev:worker": "tsx watch src/jobs/worker.ts",
"build": "tsc",
"start": "node dist/app.js",
"start:worker": "node dist/jobs/worker.js",
"lint": "eslint .",
"clean": "rimraf node_modules dist .turbo"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/client-ses": "^3.937.0",
"@overnightjs/core": "^1.7.6",
"@plunk/db": "*",
"@plunk/email": "*",
"@plunk/shared": "*",
"@plunk/types": "*",
"@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.63.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"helmet": "^8.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"signale": "^1.4.0",
"stripe": "^20.0.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/signale": "^1.4.7",
"concurrently": "^9.2.1",
"tsx": "^4.20.6"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Note: To run these integration tests, you need to:
// 1. Have the API server running or import the app instance
// 2. For now, these tests demonstrate the pattern for integration testing
describe('Campaigns API Integration Tests', () => {
let projectId: string;
let _authToken: string;
let _apiUrl: string;
const prisma = getPrismaClient();
beforeAll(() => {
// In a real setup, you'd either:
// 1. Import the Express app instance
// 2. Or use the actual API server URL
_apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
});
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
// Generate auth token (you'd need to implement this based on your auth system)
// This is a placeholder - adjust based on your actual auth implementation
_authToken = 'test-jwt-token';
});
describe('POST /campaigns', () => {
it('should create a new campaign', async () => {
const campaignData = {
name: 'Test Campaign',
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.ALL,
};
// Example of how the integration test would look
// Uncomment when you have the app instance available:
/*
const response = await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${authToken}`)
.send(campaignData)
.expect(201);
expect(response.body.name).toBe('Test Campaign');
expect(response.body.status).toBe(CampaignStatus.DRAFT);
*/
// For now, just verify the factory can create the data
const campaign = await factories.createCampaign({
projectId,
...campaignData,
});
expect(campaign.name).toBe('Test Campaign');
});
it('should validate required fields', async () => {
// Test that API validates required fields
// This would fail without name, subject, etc.
const _invalidData = {
from: 'test@example.com',
};
// When integrated with supertest:
/*
await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${_authToken}`)
.send(_invalidData)
.expect(400);
*/
});
});
describe('GET /campaigns', () => {
it('should list campaigns with pagination', async () => {
// Create test campaigns using bulk insert to avoid memory issues
const campaignData = Array.from({length: 25}, (_, i) => ({
projectId,
name: `Campaign ${i}`,
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
status: 'DRAFT' as const,
}));
await prisma.campaign.createMany({data: campaignData});
// When integrated:
/*
const response = await request(app)
.get('/campaigns')
.query({ page: 1, pageSize: 10 })
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.campaigns).toHaveLength(10);
expect(response.body.total).toBe(25);
expect(response.body.totalPages).toBe(3);
*/
const campaigns = await prisma.campaign.findMany({
where: {projectId},
take: 10,
});
expect(campaigns.length).toBeLessThanOrEqual(10);
});
it('should filter campaigns by status', async () => {
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.COMPLETED});
// When integrated:
/*
const response = await request(app)
.get('/campaigns')
.query({ status: CampaignStatus.DRAFT })
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
*/
const draftCampaigns = await prisma.campaign.findMany({
where: {projectId, status: CampaignStatus.DRAFT},
});
expect(draftCampaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
describe('PUT /campaigns/:id', () => {
it('should update a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
name: 'Original Name',
});
// When integrated:
/*
const response = await request(app)
.put(`/campaigns/${campaign.id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body.name).toBe('Updated Name');
*/
const updated = await prisma.campaign.update({
where: {id: campaign.id},
data: {name: 'Updated Name'},
});
expect(updated.name).toBe('Updated Name');
});
it('should not update a completed campaign', async () => {
const _campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.COMPLETED,
});
// When integrated:
/*
await request(app)
.put(`/campaigns/${_campaign.id}`)
.set('Authorization', `Bearer ${_authToken}`)
.send({ name: 'New Name' })
.expect(400);
*/
});
});
describe('DELETE /campaigns/:id', () => {
it('should delete a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
// When integrated:
/*
await request(app)
.delete(`/campaigns/${campaign.id}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(204);
const deleted = await prisma.campaign.findUnique({ where: { id: campaign.id } });
expect(deleted).toBeNull();
*/
await prisma.campaign.delete({where: {id: campaign.id}});
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
expect(deleted).toBeNull();
});
});
});
@@ -0,0 +1,458 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../../services/DomainService.js';
import * as SESService from '../../services/SESService.js';
/**
* Integration tests for Domain verification and ownership checks
* Tests the security feature that prevents domains from being linked to multiple projects
* unless the user is a member of the project that owns the domain
*/
describe('Domain Verification and Ownership Tests', () => {
const prisma = getPrismaClient();
// Mock SES service to avoid external AWS calls
beforeEach(() => {
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
status: 'Success',
tokens: ['token1', 'token2', 'token3'],
});
});
// ========================================
// DOMAIN OWNERSHIP CHECKS
// ========================================
describe('Domain Ownership Verification', () => {
it('should allow adding a domain that does not exist yet', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'new-domain.com';
const ownershipCheck = await DomainService.checkDomainOwnership(domain, 'any-user-id');
expect(ownershipCheck.exists).toBe(false);
// Should be able to add the domain
const newDomain = await DomainService.addDomain(project.id, domain);
expect(newDomain.domain).toBe(domain);
expect(newDomain.projectId).toBe(project.id);
});
it('should detect when a domain already exists', async () => {
const {user, project} = await factories.createUserWithProject();
const domain = 'existing-domain.com';
// First project adds the domain
await DomainService.addDomain(project.id, domain);
// Check ownership - user IS a member of the project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.projectId).toBe(project.id);
expect(ownershipCheck.projectName).toBe(project.name);
expect(ownershipCheck.isMember).toBe(true);
});
it('should detect when a domain exists but user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
const domain = 'other-project-domain.com';
// Project 1 adds the domain
await DomainService.addDomain(project1.id, domain);
// Check ownership for User 2 (not a member of Project 1)
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
expect(ownershipCheck.isMember).toBe(false);
});
it('should allow access when user is a member of the project that owns the domain', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'shared-domain.com';
// User 1 adds the domain to their project
await DomainService.addDomain(project.id, domain);
// Create another user and add them to the same project
const user2 = await factories.createUser({email: 'user2@test.com'});
await prisma.membership.create({
data: {
userId: user2.id,
projectId: project.id,
role: 'ADMIN',
},
});
// Check ownership for User 2 (who is now a member)
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
});
});
// ========================================
// PREVENTING UNAUTHORIZED DOMAIN LINKING
// ========================================
describe('Preventing Unauthorized Domain Linking', () => {
it('should prevent linking a domain to another project when user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
const domain = 'protected-domain.com';
// Project 1 adds the domain
await DomainService.addDomain(project1.id, domain);
// User 2 tries to link the same domain to Project 2
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
// The controller would use this check to deny access
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(false);
// Verify the domain is still only linked to Project 1
const domains = await prisma.domain.findMany({
where: {domain},
});
expect(domains).toHaveLength(1);
expect(domains[0].projectId).toBe(project1.id);
});
it('should allow member to see they can access domain from the original project', async () => {
const {user, project: project1} = await factories.createUserWithProject();
const domain = 'member-domain.com';
// Add domain to project 1
await DomainService.addDomain(project1.id, domain);
// User creates a second project (same user, different project)
const project2 = await factories.createProject();
await prisma.membership.create({
data: {
userId: user.id,
projectId: project2.id,
role: 'OWNER',
},
});
// User tries to link the domain to project 2
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
// Should indicate that domain exists and user IS a member
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
expect(ownershipCheck.projectName).toBe(project1.name);
});
});
// ========================================
// DOMAIN VERIFICATION STATUS
// ========================================
describe('Domain Verification Status', () => {
it('should create unverified domain when first added', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'unverified-domain.com';
const newDomain = await DomainService.addDomain(project.id, domain);
expect(newDomain.verified).toBe(false);
expect(newDomain.dkimTokens).toEqual(['token1', 'token2', 'token3']);
});
it('should prevent using unverified domain for sending emails', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'unverified-domain.com';
await DomainService.addDomain(project.id, domain);
// Try to verify email domain
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not verified/i);
});
it('should allow using verified domain for sending emails', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'verified-domain.com';
const newDomain = await DomainService.addDomain(project.id, domain);
// Manually mark as verified (simulating DNS verification)
await prisma.domain.update({
where: {id: newDomain.id},
data: {verified: true},
});
// Should not throw error
const verifiedDomain = await DomainService.verifyEmailDomain(`sender@${domain}`, project.id);
expect(verifiedDomain.verified).toBe(true);
expect(verifiedDomain.domain).toBe(domain);
});
it('should prevent using domain from different project', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const domain = 'project1-domain.com';
const newDomain = await DomainService.addDomain(project1.id, domain);
// Mark as verified
await prisma.domain.update({
where: {id: newDomain.id},
data: {verified: true},
});
// Try to use from different project
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project2.id)).rejects.toThrow(
/belongs to a different project/i,
);
});
it('should prevent using unregistered domain', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'not-registered.com';
// Try to use domain that was never added
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not registered/i);
});
});
// ========================================
// MULTIPLE USERS AND PROJECTS
// ========================================
describe('Multiple Users and Projects Scenarios', () => {
it('should handle 3 users: owner, member, and non-member', async () => {
const {user: owner, project} = await factories.createUserWithProject();
const member = await factories.createUser({email: 'member@test.com'});
const nonMember = await factories.createUser({email: 'nonmember@test.com'});
const domain = 'team-domain.com';
// Owner adds domain
await DomainService.addDomain(project.id, domain);
// Add member to project
await prisma.membership.create({
data: {
userId: member.id,
projectId: project.id,
role: 'MEMBER',
},
});
// Check ownership for each user
const ownerCheck = await DomainService.checkDomainOwnership(domain, owner.id);
const memberCheck = await DomainService.checkDomainOwnership(domain, member.id);
const nonMemberCheck = await DomainService.checkDomainOwnership(domain, nonMember.id);
expect(ownerCheck.isMember).toBe(true);
expect(memberCheck.isMember).toBe(true);
expect(nonMemberCheck.isMember).toBe(false);
});
it('should handle user with multiple projects', async () => {
const {user, project: project1} = await factories.createUserWithProject();
const project2 = await factories.createProject();
const domain = 'multi-project-domain.com';
// Add user to second project
await prisma.membership.create({
data: {
userId: user.id,
projectId: project2.id,
role: 'ADMIN',
},
});
// Add domain to project 1
await DomainService.addDomain(project1.id, domain);
// User is member of both projects, but domain belongs to project 1
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
});
it('should handle domain being removed and re-added', async () => {
const {user, project} = await factories.createUserWithProject();
const domain = 'reusable-domain.com';
// Add domain
const domain1 = await DomainService.addDomain(project.id, domain);
// Remove domain
await DomainService.removeDomain(domain1.id);
// Check ownership - should not exist
const checkAfterRemoval = await DomainService.checkDomainOwnership(domain, user.id);
expect(checkAfterRemoval.exists).toBe(false);
// Re-add domain
const domain2 = await DomainService.addDomain(project.id, domain);
expect(domain2.domain).toBe(domain);
expect(domain2.projectId).toBe(project.id);
});
});
// ========================================
// ROLE-BASED RESTRICTIONS
// ========================================
describe('Role-Based Domain Access', () => {
it('should verify that only ADMIN and OWNER can add domains', async () => {
const {project} = await factories.createUserWithProject();
const regularMember = await factories.createUser({email: 'member@test.com'});
await prisma.membership.create({
data: {
userId: regularMember.id,
projectId: project.id,
role: 'MEMBER',
},
});
// The controller checks for role: { in: ['ADMIN', 'OWNER'] }
// This test verifies the database structure supports this check
const membership = await prisma.membership.findFirst({
where: {
userId: regularMember.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
expect(membership).toBeNull();
});
it('should verify that ADMIN and OWNER roles can add domains', async () => {
const {user: owner, project} = await factories.createUserWithProject();
const admin = await factories.createUser({email: 'admin@test.com'});
await prisma.membership.create({
data: {
userId: admin.id,
projectId: project.id,
role: 'ADMIN',
},
});
// Verify both owner and admin have required roles
const ownerMembership = await prisma.membership.findFirst({
where: {
userId: owner.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
const adminMembership = await prisma.membership.findFirst({
where: {
userId: admin.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
expect(ownerMembership).not.toBeNull();
expect(adminMembership).not.toBeNull();
});
});
// ========================================
// EDGE CASES
// ========================================
describe('Edge Cases', () => {
it('should handle case-sensitive domain names', async () => {
const {project} = await factories.createUserWithProject();
// Domains are typically case-insensitive in DNS, but stored as-is in DB
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
expect(domain1.domain).toBe('Example.com');
});
it('should handle subdomain vs root domain', async () => {
const {project} = await factories.createUserWithProject();
const rootDomain = await DomainService.addDomain(project.id, 'example.com');
const subDomain = await DomainService.addDomain(project.id, 'mail.example.com');
expect(rootDomain.domain).toBe('example.com');
expect(subDomain.domain).toBe('mail.example.com');
// Both should be separate entries
const domains = await prisma.domain.findMany({
where: {projectId: project.id},
});
expect(domains).toHaveLength(2);
});
it('should handle invalid email format in verifyEmailDomain', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('not-an-email', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('multiple@at@signs.com', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
});
// ========================================
// GET PROJECT DOMAINS
// ========================================
describe('Get Project Domains', () => {
it('should return all domains for a project', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'domain1.com');
await DomainService.addDomain(project.id, 'domain2.com');
await DomainService.addDomain(project.id, 'domain3.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(3);
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com', 'domain3.com']);
});
it('should return empty array for project with no domains', async () => {
const {project} = await factories.createUserWithProject();
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(0);
});
it('should not return domains from other projects', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
await DomainService.addDomain(project1.id, 'project1-domain.com');
await DomainService.addDomain(project2.id, 'project2-domain.com');
const project1Domains = await DomainService.getProjectDomains(project1.id);
const project2Domains = await DomainService.getProjectDomains(project2.id);
expect(project1Domains).toHaveLength(1);
expect(project1Domains[0].domain).toBe('project1-domain.com');
expect(project2Domains).toHaveLength(1);
expect(project2Domains[0].domain).toBe('project2-domain.com');
});
});
});
+422
View File
@@ -0,0 +1,422 @@
import {Server} from '@overnightjs/core';
import cookies from 'cookie-parser';
import cors from 'cors';
import type {NextFunction, Request, Response} from 'express';
import {json, raw} from 'express';
import helmet from 'helmet';
import morgan from 'morgan';
import signale from 'signale';
import {ZodError} from 'zod';
import {
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
PLUNK_ENABLED,
PORT,
S3_ENABLED,
SMTP_ENABLED,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI
} from './app/constants.js';
import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js';
import {Analytics} from './controllers/Analytics.js';
import {Auth} from './controllers/Auth.js';
import {Campaigns} from './controllers/Campaigns.js';
import {Contacts} from './controllers/Contacts.js';
import {Domains} from './controllers/Domains.js';
import {Events} from './controllers/Events.js';
import {Oauth} from './controllers/Oauth/index.js';
import {Projects} from './controllers/Projects.js';
import {Segments} from './controllers/Segments.js';
import {Templates} from './controllers/Templates.js';
import {Uploads} from './controllers/Uploads.js';
import {Users} from './controllers/Users.js';
import {Webhooks} from './controllers/Webhooks.js';
import {Workflows} from './controllers/Workflows.js';
import {Config} from './controllers/Config.js';
import {prisma} from './database/prisma.js';
import {ErrorCode, type FieldError, HttpException, ValidationError} from './exceptions/index.js';
import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js';
import * as S3Service from './services/S3Service.js';
import {requestIdMiddleware} from './middleware/requestId.js';
import {databaseRequestLogger} from './middleware/requestLogger.js';
import {logger, requestLogger} from './utils/logger.js';
const server = new (class extends Server {
public constructor() {
super();
// Specify that we need raw json for the webhook
this.app.use('/webhooks/incoming/stripe', raw({type: 'application/json'}));
// Set the content-type to JSON for any request coming from AWS SNS
this.app.use(function (req, res, next) {
if (req.get('x-amz-sns-message-type')) {
req.headers['content-type'] = 'application/json';
}
next();
});
// Parse the rest of our application as json
this.app.use(json({limit: '50mb'}));
this.app.use(cookies());
this.app.use(helmet());
// Add request ID to all requests for tracking
this.app.use(requestIdMiddleware);
// Log all requests and responses with request ID (console/file logs)
this.app.use(requestLogger);
// Log all requests to database for historical tracking and analytics
this.app.use(databaseRequestLogger);
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
res.set({'Access-Control-Allow-Origin': '*'});
next();
});
// Build allowed origins from environment variables
const allowedOrigins =
NODE_ENV === 'development'
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
this.app.use(
cors({
origin: allowedOrigins,
credentials: true,
}),
);
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
this.addControllers([
new Actions(),
new Activity(),
new Analytics(),
new Auth(),
new Campaigns(),
new Oauth(),
new Users(),
new Contacts(),
new Domains(),
new Projects(),
new Segments(),
new Templates(),
new Uploads(),
new Webhooks(),
new Workflows(),
new Events(),
new Config(),
]);
this.app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
time: Date.now(),
environment: NODE_ENV,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
});
});
this.app.get('/', (_, res) => res.redirect(LANDING_URI));
this.app.use('*', () => {
throw new HttpException(404, 'Unknown route');
});
}
})();
/**
* Standardized error response format
*/
interface ErrorResponse {
success: false;
error: {
code: string; // Machine-readable error code (e.g., "VALIDATION_ERROR")
message: string; // Human-readable error message
statusCode: number; // HTTP status code
requestId?: string; // Request ID for tracking
errors?: FieldError[]; // Field-level validation errors
details?: Record<string, unknown>; // Additional error context
suggestion?: string; // Helpful suggestion for fixing the error
};
timestamp: string; // ISO timestamp
}
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
const requestId = res.locals.requestId as string | undefined;
// Handle Zod validation errors
if (error instanceof ZodError) {
const fieldErrors: FieldError[] = error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
received: err.code !== 'invalid_type' ? undefined : 'received' in err ? err.received : undefined,
}));
const statusCode = 422;
// Create helpful suggestions based on common validation errors
let suggestion = 'Please check the API documentation for the correct request format.';
if (fieldErrors.some(e => e.code === 'invalid_type')) {
suggestion =
'One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false.';
} else if (fieldErrors.some(e => e.message.includes('required'))) {
suggestion = 'Required fields are missing. Ensure all required fields are included in your request.';
}
// Log validation errors with structured logging
logger.warn(
'Validation failed',
{
endpoint: `${req.method} ${req.path}`,
fieldCount: fieldErrors.length,
fields: fieldErrors.map(e => e.field),
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.VALIDATION_ERROR,
message: 'Request validation failed',
statusCode,
requestId,
errors: fieldErrors,
suggestion,
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle custom HTTP exceptions
if (error instanceof HttpException) {
const statusCode = error.code;
const errorCode = error.errorCode || ErrorCode.INTERNAL_SERVER_ERROR;
// Extract validation errors if this is a ValidationError
const validationError = error instanceof ValidationError ? error : null;
// Log based on severity with structured logging
if (statusCode >= 500) {
logger.error(
error.message,
error,
{
endpoint: `${req.method} ${req.path}`,
errorCode,
statusCode,
},
res,
);
} else if (statusCode >= 400) {
logger.warn(
error.message,
{
endpoint: `${req.method} ${req.path}`,
errorCode,
statusCode,
},
res,
);
}
// Generate helpful suggestions based on error type
let suggestion: string | undefined;
switch (errorCode) {
case ErrorCode.INVALID_API_KEY:
suggestion = 'Verify your API key is correct and starts with "sk_" for secret keys or "pk_" for public keys.';
break;
case ErrorCode.MISSING_AUTH:
suggestion = 'Include an Authorization header with format: "Authorization: Bearer YOUR_API_KEY"';
break;
case ErrorCode.FORBIDDEN:
suggestion =
'This action requires additional permissions. Check that you have access to this resource or contact support.';
break;
case ErrorCode.TEMPLATE_NOT_FOUND:
suggestion =
'Ensure the template ID is correct and belongs to your project. You can list available templates via the API.';
break;
case ErrorCode.VALIDATION_ERROR:
suggestion = 'Check the "errors" array for specific field-level validation issues.';
break;
case ErrorCode.RATE_LIMIT_EXCEEDED:
suggestion = 'You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan.';
break;
case ErrorCode.UPGRADE_REQUIRED:
suggestion = 'This feature requires a higher plan. Visit your dashboard to upgrade.';
break;
case ErrorCode.PROJECT_DISABLED:
suggestion = 'Your project has been disabled. Contact support for assistance.';
break;
}
const response: ErrorResponse = {
success: false,
error: {
code: errorCode,
message: error.message,
statusCode,
requestId,
errors: validationError?.errors,
details: error.details,
suggestion,
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle unexpected errors (not HttpException)
const statusCode = 500;
logger.error(
'Unexpected error occurred',
error,
{
endpoint: `${req.method} ${req.path}`,
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.INTERNAL_SERVER_ERROR,
message: 'An unexpected error occurred',
statusCode,
requestId,
suggestion: 'This is an internal server error. Please contact support with the request ID if the issue persists.',
},
timestamp: new Date().toISOString(),
};
res.status(statusCode).json(response);
});
// Global error handlers to prevent server crashes
process.on('unhandledRejection', (reason, promise) => {
signale.error('Unhandled Promise Rejection:', reason);
signale.error('Promise:', promise);
// Don't exit the process - just log the error
});
process.on('uncaughtException', error => {
signale.error('Uncaught Exception:', error);
// Don't exit the process - just log the error
});
void prisma.$connect().then(async () => {
server.app.listen(PORT, '0.0.0.0', () => signale.success('[HTTPS] Ready on', PORT));
// Feature matrix
const features = [
{
name: 'Billing (Stripe)',
enabled: STRIPE_ENABLED,
details: STRIPE_ENABLED ? 'Stripe billing enabled' : 'No Stripe keys configured',
},
{
name: 'File uploads (S3)',
enabled: S3_ENABLED,
details: S3_ENABLED ? 'S3 storage enabled' : 'S3 credentials missing',
},
{name: 'SMTP relay', enabled: SMTP_ENABLED, details: SMTP_ENABLED ? 'SMTP server enabled' : 'SMTP disabled'},
{
name: 'OAuth - GitHub',
enabled: GITHUB_OAUTH_ENABLED,
details: GITHUB_OAUTH_ENABLED ? 'GitHub login enabled' : 'GitHub OAuth not configured',
},
{
name: 'OAuth - Google',
enabled: GOOGLE_OAUTH_ENABLED,
details: GOOGLE_OAUTH_ENABLED ? 'Google login enabled' : 'Google OAuth not configured',
},
{
name: 'Tracking toggle',
enabled: TRACKING_TOGGLE_ENABLED,
details: TRACKING_TOGGLE_ENABLED
? 'Per-project tracking toggle enabled'
: 'Always tracking or always no-tracking',
},
{
name: 'Platform emails',
enabled: PLUNK_ENABLED,
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
},
];
const rows = features.map(f => ({
Feature: f.name,
Enabled: f.enabled ? '✅' : '❌',
}));
console.table(rows);
if (S3_ENABLED) {
try {
await S3Service.initializeBucket();
} catch (error) {
signale.error('[S3] Failed to initialize bucket:', error);
signale.warn('[S3] File uploads may not work properly');
}
}
// Set up repeatable job for domain verification (BullMQ)
// Run every 5 minutes to check domain verification status with AWS SES
await domainVerificationQueue.add(
'check-domain-verification',
{},
{
repeat: {
pattern: '*/5 * * * *', // Every 5 minutes
},
jobId: 'domain-verification-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] Domain verification scheduled (BullMQ repeatable job, runs every 5 minutes)');
// Set up repeatable job for segment count updates (BullMQ)
// Run every 5 minutes to compute membership changes and trigger events
await segmentCountQueue.add(
'update-segment-counts',
{},
{
repeat: {
pattern: '*/5 * * * *', // Every 5 minutes
},
jobId: 'segment-count-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] Segment count updater scheduled (BullMQ repeatable job, runs every 5 minutes)');
// Set up repeatable job for API request log cleanup (BullMQ)
// Run daily at 3 AM to clean up old request logs
await apiRequestCleanupQueue.add(
'cleanup-old-requests',
{},
{
repeat: {
pattern: '0 3 * * *', // Daily at 3 AM
},
jobId: 'api-request-cleanup-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] API request cleanup scheduled (BullMQ repeatable job, runs daily at 3 AM)');
});
+111
View File
@@ -0,0 +1,111 @@
import dotenv from 'dotenv';
dotenv.config({quiet: true});
/**
* Safely parse environment variables
* @param key The key
* @param defaultValue An optional default value if the environment variable does not exist
*/
export function validateEnv<T extends string = string>(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
const value = process.env[key] as T | undefined;
if (!value) {
if (typeof defaultValue !== 'undefined') {
return defaultValue;
} else {
throw new Error(`${key} is not defined in environment variables`);
}
}
return value;
}
// Environment
export const NODE_ENV = validateEnv('NODE_ENV', 'development');
export const JWT_SECRET = validateEnv('JWT_SECRET');
export const PORT = Number(validateEnv('PORT', '8080'));
// URLs
export const API_URI = validateEnv('API_URI');
export const DASHBOARD_URI = validateEnv('DASHBOARD_URI');
export const LANDING_URI = validateEnv('LANDING_URI');
export const WIKI_URI = validateEnv('WIKI_URI');
// S3-compatible storage (Minio)
export const S3_ENDPOINT = validateEnv('S3_ENDPOINT', 'http://minio:9000');
export const S3_ACCESS_KEY_ID = validateEnv('S3_ACCESS_KEY_ID', '');
export const S3_ACCESS_KEY_SECRET = validateEnv('S3_ACCESS_KEY_SECRET', '');
export const S3_BUCKET = validateEnv('S3_BUCKET', 'uploads');
export const S3_PUBLIC_URL = validateEnv('S3_PUBLIC_URL', '');
export const S3_FORCE_PATH_STYLE = validateEnv('S3_FORCE_PATH_STYLE', 'true') === 'true';
export const S3_ENABLED = S3_ACCESS_KEY_ID !== '' && S3_ACCESS_KEY_SECRET !== '';
// AWS SES (required for email sending)
export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing)
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
export const DIRECT_DATABASE_URL = validateEnv('DIRECT_DATABASE_URL');
// OAuth (optional - for social login)
export const GITHUB_OAUTH_CLIENT = validateEnv('GITHUB_OAUTH_CLIENT', '');
export const GITHUB_OAUTH_SECRET = validateEnv('GITHUB_OAUTH_SECRET', '');
export const GITHUB_OAUTH_ENABLED = GITHUB_OAUTH_CLIENT !== '' && GITHUB_OAUTH_SECRET !== '';
export const GOOGLE_OAUTH_CLIENT = validateEnv('GOOGLE_OAUTH_CLIENT', '');
export const GOOGLE_OAUTH_SECRET = validateEnv('GOOGLE_OAUTH_SECRET', '');
export const GOOGLE_OAUTH_ENABLED = GOOGLE_OAUTH_CLIENT !== '' && GOOGLE_OAUTH_SECRET !== '';
// Stripe (optional - if not set, billing features are disabled)
export const STRIPE_SK = validateEnv('STRIPE_SK', '');
export const STRIPE_WEBHOOK_SECRET = validateEnv('STRIPE_WEBHOOK_SECRET', '');
export const STRIPE_ENABLED = STRIPE_SK !== '' && STRIPE_WEBHOOK_SECRET !== '';
// Stripe Pricing Configuration
export const STRIPE_PRICE_ONBOARDING = validateEnv('STRIPE_PRICE_ONBOARDING', ''); // One-time onboarding fee
export const STRIPE_PRICE_EMAIL_USAGE = validateEnv('STRIPE_PRICE_EMAIL_USAGE', ''); // Metered usage price for pay-per-email
export const STRIPE_METER_EVENT_NAME = validateEnv('STRIPE_METER_EVENT_NAME', 'emails'); // Meter event name (API key in Stripe)
// Email Tracking
export const SES_CONFIGURATION_SET = validateEnv('SES_CONFIGURATION_SET', 'plunk-configuration-set');
export const SES_CONFIGURATION_SET_NO_TRACKING = validateEnv(
'SES_CONFIGURATION_SET_NO_TRACKING',
'plunk-configuration-set-no-tracking',
);
// Check if no-tracking configuration set was explicitly provided (not using default)
export const TRACKING_TOGGLE_ENABLED = process.env.SES_CONFIGURATION_SET_NO_TRACKING !== undefined;
// SMTP Server Configuration (optional)
// SMTP server can run with or without a domain (runs without TLS in dev mode)
// Check if we should enable SMTP features in the UI
export const SMTP_DOMAIN = validateEnv('SMTP_DOMAIN', 'localhost');
export const SMTP_PORT_SECURE = Number(validateEnv('PORT_SECURE', '465'));
export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587'));
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
export const SMTP_ENABLED =
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Security (optional)
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
+14
View File
@@ -0,0 +1,14 @@
import Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_SK} from './constants.js';
/**
* Stripe client instance
* Only initialized if STRIPE_SK is configured
* Returns null if billing is disabled (self-hosted mode)
*/
export const stripe = STRIPE_ENABLED
? new Stripe(STRIPE_SK, {
apiVersion: '2025-11-17.clover',
})
: null;
+399
View File
@@ -0,0 +1,399 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {EventService} from '../services/EventService.js';
import {NotFound, ValidationError} from '../exceptions/index.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {DASHBOARD_URI} from '../app/constants.js';
/**
* Public API Actions Controller
* Handles track event, transactional email, and email verification endpoints
*/
@Controller('v1')
export class Actions {
/**
* POST /v1/track
* Track an event for a contact (creates/updates contact and tracks event)
*
* Request body:
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
*
* Response:
* - success: boolean
* - data: object with contact ID, event ID, and timestamp
*
* Example:
* {
* event: "purchase",
* email: "user@example.com",
* data: {
* totalSpent: 1500, // Persistent - saved to contact
* plan: "pro", // Persistent - saved to contact
* orderId: {value: "12345", persistent: false}, // Non-persistent - workflows only
* receiptUrl: {value: "https://...", persistent: false} // Non-persistent - workflows only
* }
* }
*/
@Post('track')
@Middleware([requirePublicKey])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
// Prevent manual tracking of reserved system events
if (EventService.isReservedEvent(event)) {
throw new ValidationError(
[
{
field: 'event',
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
code: 'reserved_event',
received: event,
},
],
'Cannot track reserved system event',
);
}
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
const contact = await ContactService.upsert(
auth.projectId,
email,
data as Record<string, unknown> | undefined,
subscribed,
);
// Track the event with ALL data (persistent + non-persistent)
// Non-persistent data flows to workflows via execution context
const eventRecord = await EventService.trackEvent(
auth.projectId,
event,
contact.id,
undefined,
data as Record<string, unknown> | undefined,
);
return res.status(200).json({
success: true,
data: {
contact: contact.id,
event: eventRecord.id,
timestamp: eventRecord.createdAt.toISOString(),
},
});
}
/**
* POST /v1/send
* Send transactional email(s)
*
* Request body:
* - to: string | object | array (required) - Recipient email(s)
* - String: "user@example.com"
* - Object: {name: "Jane Doe", email: "user@example.com"}
* - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
* - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
* - filename: string (required) - Attachment filename
* - content: string (required) - Base64 encoded file content
* - contentType: string (required) - MIME type (e.g., "application/pdf")
*
* Response:
* - success: boolean
* - data: object with emails array and timestamp
*
* Examples:
*
* Simple format (backward compatible):
* {
* to: "user@example.com",
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
* from: "noreply@example.com",
* name: "My App",
* data: {
* firstName: "John", // Persistent - saved to contact
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
* }
* }
*
* Object format (recommended):
* {
* to: {
* name: "Jane Doe",
* email: "user@example.com"
* },
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p>",
* from: {
* name: "My App",
* email: "noreply@example.com"
* }
* }
*
* Multiple recipients with names:
* {
* to: [
* {name: "Jane Doe", email: "jane@example.com"},
* {name: "John Smith", email: "john@example.com"}
* ],
* subject: "Newsletter",
* body: "<p>Hello {{name}}!</p>",
* from: {name: "Newsletter", email: "news@example.com"}
* }
*/
@Post('send')
@Middleware([requireSecretKey])
@CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body);
// Normalize recipients to array and parse email/name
type Recipient = {email: string; name?: string};
const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => {
if (typeof recipient === 'string') {
return {email: recipient};
} else {
return {email: recipient.email, name: recipient.name};
}
});
// Parse 'from' field - can be string or object {name, email}
let emailFrom: string | undefined;
let emailFromName: string | undefined;
if (typeof from === 'string') {
// Backward compatible: from is just an email string
emailFrom = from;
emailFromName = name; // Use separate 'name' field if provided
} else if (from && typeof from === 'object') {
// New format: from is an object with {name, email}
emailFrom = from.email;
emailFromName = from.name || name; // Prefer from.name, fallback to separate 'name' field
} else {
// No 'from' provided
emailFromName = name;
}
// Fetch template if provided
let emailSubject = subject;
let emailBody = body;
let emailReplyTo = reply;
let templateId: string | undefined;
if (template) {
const templateRecord = await prisma.template.findUnique({
where: {
id: template,
projectId: auth.projectId, // Ensure template belongs to this project
},
});
if (!templateRecord) {
throw new NotFound('Template', template);
}
// Use template values, allow overrides from request
emailSubject = subject || templateRecord.subject;
emailBody = body || templateRecord.body;
// Handle from field - if not already set and template has a from, use it
if (!emailFrom && templateRecord.from) {
emailFrom = templateRecord.from;
}
if (!emailFromName && templateRecord.fromName) {
emailFromName = templateRecord.fromName;
}
emailReplyTo = reply || templateRecord.replyTo || undefined;
templateId = templateRecord.id;
}
if (!emailFrom) {
throw new ValidationError(
[
{
field: 'from',
message: 'Sender email is required either in request or template',
code: 'required',
},
],
'Could not parse sender email',
);
}
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
const replyToEmail = emailReplyTo;
const timestamp = new Date();
const emailResults = [];
// Process each recipient
for (const recipient of recipients) {
// Merge recipient name with data if provided
const recipientData = recipient.name
? {...(data as Record<string, unknown> | undefined), name: recipient.name}
: (data as Record<string, unknown> | undefined);
// Create or update contact with metadata
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
// Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
// Add system variables (email, unsubscribe URLs, etc.) to merged data
// These are always available for template rendering
const dataWithSystemVars = {
...mergedData,
email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
// Render template with contact data
// Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!;
let renderedBody = emailBody!;
for (const [key, value] of Object.entries(dataWithSystemVars)) {
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
// Replace with value
const stringValue = value !== null && value !== undefined ? String(value) : '';
renderedSubject = renderedSubject!.replace(placeholder, stringValue);
renderedBody = renderedBody!.replace(placeholder, stringValue);
// Handle fallback syntax: {{field ?? default}}
renderedSubject = renderedSubject!.replace(fallbackPlaceholder, stringValue || '$1');
renderedBody = renderedBody!.replace(fallbackPlaceholder, stringValue || '$1');
}
// Replace any remaining placeholders with empty string or fallback value
renderedSubject = renderedSubject!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
renderedBody = renderedBody!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
// Handle fallback placeholders that weren't matched
renderedSubject = renderedSubject!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
renderedBody = renderedBody!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
const email = await EmailService.sendTransactionalEmail({
projectId: auth.projectId,
contactId: contact.id,
subject: renderedSubject,
body: renderedBody,
from: emailFrom,
fromName: emailFromName,
toName: recipient.name,
replyTo: replyToEmail,
headers: headers || undefined,
attachments: attachments || undefined,
templateId: templateId,
});
emailResults.push({
contact: {
id: contact.id,
email: contact.email,
},
email: email.id,
});
}
return res.status(200).json({
success: true,
data: {
emails: emailResults,
timestamp: timestamp.toISOString(),
},
});
}
/**
* POST /v1/verify
* Verify an email address
*
* Request body:
* - email: string (required) - Email address to verify
*
* Response:
* - success: boolean
* - data: object with verification results
* - email: string - Email address that was verified
* - valid: boolean - Whether the email appears to be valid
* - isDisposable: boolean - Whether the email is from a disposable domain
* - hasMxRecords: boolean - Whether the domain has MX records configured
* - suggestedEmail?: string - Suggested correction if typo detected
* - reasons: string[] - Array of reasons describing the verification results
*
* Example:
* {
* email: "user@gmial.com"
* }
*
* Response:
* {
* success: true,
* data: {
* email: "user@gmial.com",
* valid: false,
* isDisposable: false,
* hasMxRecords: false,
* suggestedEmail: "user@gmail.com",
* reasons: [
* "Possible typo detected, did you mean user@gmail.com?",
* "Domain does not exist or has no MX records"
* ]
* }
* }
*/
@Post('verify')
@Middleware([requireSecretKey])
@CatchAsync
public async verify(req: Request, res: Response, _next: NextFunction) {
// Zod validation - errors automatically handled by global error handler
const {email} = ActionSchemas.verify.parse(req.body);
// Verify the email address
const verificationResult = await EmailVerificationService.verifyEmail(email);
return res.status(200).json({
success: true,
data: verificationResult,
});
}
}
+128
View File
@@ -0,0 +1,128 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity')
export class Activity {
/**
* GET /activity
* Get unified activity feed for the project
*
* Query params:
* - limit: number (default 50, max 100)
* - cursor: string (pagination cursor: timestamp_id)
* - types: ActivityType[] (filter by activity types)
* - contactId: string (filter by contact)
* - startDate: ISO date string
* - endDate: ISO date string
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId as string | undefined;
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;
// Parse types filter (comma-separated)
let types: ActivityType[] | undefined;
if (req.query.types) {
const typesParam = req.query.types as string;
types = typesParam
.split(',')
.filter(t => Object.values(ActivityType).includes(t as ActivityType)) as ActivityType[];
}
const result = await ActivityService.getActivities(
auth.projectId,
limit,
cursor,
types,
contactId,
startDate,
endDate,
);
return res.status(200).json(result);
}
/**
* GET /activity/stats
* Get activity statistics for the project
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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 stats = await ActivityService.getStats(auth.projectId, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /activity/recent-count
* Get count of recent activities (for real-time updates)
*
* Query params:
* - minutes: number (default 5)
*/
@Get('recent-count')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
return res.status(200).json({count, minutes});
}
/**
* GET /activity/types
* Get available activity types (for UI filters)
*/
@Get('types')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType);
return res.status(200).json({types});
}
/**
* GET /activity/upcoming
* Get upcoming scheduled activities (campaigns and workflow emails)
*
* Query params:
* - limit: number (default 50, max 100)
* - daysAhead: number (default 30, max 90)
*/
@Get('upcoming')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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 activities = await ActivityService.getUpcomingActivities(auth.projectId, limit, daysAhead);
return res.status(200).json({activities});
}
}
+104
View File
@@ -0,0 +1,104 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('analytics')
export class Analytics {
/**
* GET /analytics/timeseries
* Get time series data for email analytics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago, max 90 days)
* - endDate: ISO date string (defaults to now)
*
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/
@Get('timeseries')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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 timeSeries = await AnalyticsService.getTimeSeriesData(auth.projectId, startDate, endDate);
return res.status(200).json(timeSeries);
}
/**
* GET /analytics/top-campaigns
* Get top performing campaigns by open rate
*
* Query params:
* - limit: number (default 10)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('top-campaigns')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const topCampaigns = await AnalyticsService.getTopCampaigns(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topCampaigns);
}
/**
* GET /analytics/campaign-stats
* Get campaign overview statistics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns aggregate stats: total campaigns, active, completed, average rates
*/
@Get('campaign-stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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 campaignStats = await AnalyticsService.getCampaignStats(auth.projectId, startDate, endDate);
return res.status(200).json(campaignStats);
}
/**
* GET /analytics/top-events
* Get most frequently triggered events
*
* Query params:
* - limit: number (default 5, max 20)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns events sorted by frequency with trend data
*/
@Get('top-events')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const topEvents = await AnalyticsService.getTopEvents(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topEvents);
}
}
+292
View File
@@ -0,0 +1,292 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';
import {
DASHBOARD_URI,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
import {jwt, parseJwt} from '../middleware/auth.js';
import {AuthService} from '../services/AuthService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('auth')
export class Auth {
@Post('login')
@CatchAsync
public async login(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
const user = await UserService.email(email);
if (!user) {
return res.json({success: false, data: 'Incorrect email or password'});
}
if (user.type === 'PASSWORD' && !user.password) {
return res.json({success: 'redirect', redirect: `/auth/reset?id=${user.id}`});
}
const verified = await AuthService.verifyCredentials(email, password);
if (!verified) {
return res.json({success: false, data: 'Incorrect email or password'});
}
await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
return res
.cookie(UserService.COOKIE_NAME, token, cookie)
.json({success: true, data: {id: user.id, email: user.email}});
}
@Post('signup')
@CatchAsync
public async signup(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
const user = await UserService.email(email);
if (user) {
return res.json({
success: false,
data: 'That email is already associated with another user',
});
}
const created_user = await prisma.user.create({
data: {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
// Auto-verify email if platform emails are disabled
emailVerified: !PLUNK_ENABLED,
},
});
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
// Send email verification if platform emails are enabled
if (PLUNK_ENABLED) {
const verificationToken = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(verificationToken),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
);
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
await sendPlatformEmail(
created_user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {
email: created_user.email,
verificationUrl,
landingUrl: LANDING_URI,
}),
);
}
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
success: true,
data: {id: created_user.id, email: created_user.email},
});
}
@Get('logout')
public logout(req: Request, res: Response) {
res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
return res.json(true);
}
@Get('oauth-config')
public oauthConfig(req: Request, res: Response) {
return res.json({
success: true,
data: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
});
}
@Post('verify-email')
@CatchAsync
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
// Look up token in Redis
const data = await redis.get(Keys.User.emailVerificationToken(token));
if (!data) {
throw new BadRequest('Invalid or expired verification token');
}
const {userId} = JSON.parse(data);
// Update user
await prisma.user.update({
where: {id: userId},
data: {emailVerified: true},
});
// Delete token (single use) and invalidate cache
await redis.del(Keys.User.emailVerificationToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Email verified successfully'}});
}
@Post('request-verification')
@CatchAsync
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
const userId = parseJwt(req);
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
if (user.emailVerified) {
return res.json({success: true, data: {message: 'Email already verified'}});
}
// Check rate limit
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
throw new RateLimitError('Too many verification emails sent. Please try again later.');
}
// Generate token
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
);
// Send email
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
await sendPlatformEmail(
user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
return res.json({success: true, data: {message: 'Verification email sent'}});
}
@Post('request-password-reset')
@CatchAsync
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
// Check rate limit
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
// Still return success to prevent enumeration
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
// Look up user
const user = await UserService.email(email);
// Only send email if user exists and is PASSWORD type
if (user && user.type === 'PASSWORD') {
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.passwordResetToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
);
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
await sendPlatformEmail(
user.email,
'Reset your password',
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
}
// Always return success (prevent enumeration)
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
@Post('reset-password')
@CatchAsync
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
// Look up token
const data = await redis.get(Keys.User.passwordResetToken(token));
if (!data) {
throw new BadRequest('Invalid or expired reset token');
}
const {userId} = JSON.parse(data);
// Get user and verify type
const user = await prisma.user.findUnique({where: {id: userId}});
if (!user || user.type !== 'PASSWORD') {
throw new BadRequest('Invalid reset token');
}
// Hash new password and update
const hashedPassword = await AuthService.generateHash(newPassword);
await prisma.user.update({
where: {id: userId},
data: {password: hashedPassword},
});
// Delete token and invalidate cache
await redis.del(Keys.User.passwordResetToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Password reset successfully'}});
}
}
+284
View File
@@ -0,0 +1,284 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('campaigns')
export class Campaigns {
/**
* Create a new campaign
* POST /campaigns
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
if (audienceType === CampaignAudienceType.FILTERED && !audienceCondition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId);
const campaign = await CampaignService.create(auth.projectId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
audienceType,
audienceCondition,
segmentId,
});
return res.status(201).json({
success: true,
data: campaign,
});
}
/**
* Get all campaigns for a project
* GET /campaigns
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
// Validate status if provided
if (status && !Object.values(CampaignStatus).includes(status)) {
throw new HttpException(400, 'Invalid status value');
}
const result = await CampaignService.list(auth.projectId, {
status,
page,
pageSize,
});
return res.json({
campaigns: result.campaigns,
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
});
}
/**
* Get a specific campaign
* GET /campaigns/:id
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
});
}
/**
* Update a campaign
* PUT /campaigns/:id
*/
@Put(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body;
// Validate audience-specific fields if audienceType is being updated
if (audienceType === CampaignAudienceType.SEGMENT && segmentId === undefined) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
if (audienceType === CampaignAudienceType.FILTERED && audienceCondition === undefined) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId);
}
const campaign = await CampaignService.update(auth.projectId, id!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
audienceType,
audienceCondition,
segmentId,
});
return res.json({
success: true,
data: campaign,
});
}
/**
* Delete a campaign
* DELETE /campaigns/:id
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!);
return res.json({
success: true,
message: 'Campaign deleted successfully',
});
}
/**
* Duplicate a campaign
* POST /campaigns/:id/duplicate
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!);
return res.status(201).json({
success: true,
data: campaign,
message: 'Campaign duplicated successfully',
});
}
/**
* Send or schedule a campaign
* POST /campaigns/:id/send
*/
@Post(':id/send')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided
let scheduledDate: Date | undefined;
if (scheduledFor) {
scheduledDate = new Date(scheduledFor);
if (isNaN(scheduledDate.getTime())) {
throw new HttpException(400, 'Invalid scheduledFor date format');
}
}
const campaign = await CampaignService.send(auth.projectId, id!, scheduledDate);
return res.json({
success: true,
data: campaign,
message: scheduledDate ? `Campaign scheduled for ${scheduledDate.toISOString()}` : 'Campaign is being sent',
});
}
/**
* Cancel a campaign
* POST /campaigns/:id/cancel
*/
@Post(':id/cancel')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
message: 'Campaign cancelled successfully',
});
}
/**
* Get campaign statistics
* GET /campaigns/:id/stats
*/
@Get(':id/stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!);
return res.json({
success: true,
data: stats,
});
}
/**
* Send a test email for a campaign
* POST /campaigns/:id/test
*/
@Post(':id/test')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email);
return res.json({
success: true,
message: `Test email sent to ${email}`,
});
}
}
+64
View File
@@ -0,0 +1,64 @@
import {Controller, Get} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
S3_ENABLED,
SMTP_DOMAIN,
SMTP_ENABLED,
SMTP_PORT_SECURE,
SMTP_PORT_SUBMISSION,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI,
} from '../app/constants.js';
@Controller('config')
export class Config {
/**
* GET /config
* Expose a unified view of instance capabilities and feature flags for frontends.
*/
@Get('')
public getConfig(req: Request, res: Response) {
return res.status(200).json({
environment: NODE_ENV,
urls: {
api: API_URI,
dashboard: DASHBOARD_URI,
landing: LANDING_URI,
wiki: WIKI_URI || null,
},
features: {
billing: {
enabled: STRIPE_ENABLED,
},
storage: {
s3Enabled: S3_ENABLED,
},
authProviders: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
email: {
trackingToggleEnabled: TRACKING_TOGGLE_ENABLED,
},
smtp: {
enabled: SMTP_ENABLED,
domain: SMTP_ENABLED ? SMTP_DOMAIN : null,
ports: SMTP_ENABLED
? {
secure: SMTP_PORT_SECURE,
submission: SMTP_PORT_SUBMISSION,
}
: null,
},
},
});
}
}
+529
View File
@@ -0,0 +1,529 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true);
} else {
cb(new Error('Only CSV files are allowed'));
}
},
});
@Controller('contacts')
export class Contacts {
/**
* GET /contacts
* List all contacts for the authenticated project with cursor-based pagination
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const search = req.query.search as string | undefined;
const result = await ContactService.list(auth.projectId!, limit, cursor, search);
return res.status(200).json(result);
}
/**
* GET /contacts/fields
* Get all available contact fields (both standard and custom fields from data JSON)
* Returns field names with inferred types (string, number, boolean, date)
*/
@Get('fields')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
return res.status(200).json({
fields: fieldsWithTypes,
count: fieldsWithTypes.length,
});
} catch (error) {
signale.error('[CONTACTS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /contacts/fields/:field/values
* Get unique values for a contact field (for workflow conditions, segment filters, etc.)
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/
@Get('fields/:field/values')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const values = await ContactService.getUniqueFieldValues(auth.projectId!, field, limit);
return res.status(200).json({
field,
values,
count: values.length,
limit,
});
} catch (error) {
signale.error('[CONTACTS] Failed to get field values:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values',
});
}
}
/**
* GET /contacts/:id
* Get a specific contact by ID
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.get(auth.projectId!, contactId);
return res.status(200).json(contact);
}
/**
* POST /contacts
* Create or update a contact (upsert)
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {email, data, subscribed} = req.body;
if (!email) {
return res.status(400).json({error: 'Email is required'});
}
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(auth.projectId!, email);
const isUpdate = !!existingContact;
const contact = await ContactService.upsert(auth.projectId!, email, data, subscribed);
return res.status(isUpdate ? 200 : 201).json({
...contact,
_meta: {
isNew: !isUpdate,
isUpdate,
},
});
}
/**
* PATCH /contacts/:id
* Update a contact
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
const {email, data, subscribed} = req.body;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.update(auth.projectId!, contactId, {email, data, subscribed});
return res.status(200).json(contact);
}
/**
* DELETE /contacts/:id
* Delete a contact
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
await ContactService.delete(auth.projectId!, contactId);
return res.status(204).send();
}
/**
* GET /contacts/public/:id
* PUBLIC: Get contact information (no auth required)
*/
@Get('public/:id')
@CatchAsync
public async getPublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.getById(contactId);
// Fetch project to get language preference
const project = await ContactService.getProjectByContactId(contactId);
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
language: contactLocale || project?.language || 'en',
});
}
/**
* POST /contacts/public/:id/subscribe
* PUBLIC: Subscribe a contact (no auth required)
*/
@Post('public/:id/subscribe')
@CatchAsync
public async subscribePublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.subscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/public/:id/unsubscribe
* PUBLIC: Unsubscribe a contact (no auth required)
*/
@Post('public/:id/unsubscribe')
@CatchAsync
public async unsubscribePublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.unsubscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/import
* Import contacts from CSV file
*/
@Post('import')
@Middleware([requireAuth, upload.single('file')])
@CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
if (!req.file) {
return res.status(400).json({error: 'CSV file is required'});
}
try {
// Convert file buffer to base64 for storage in queue
const csvData = req.file.buffer.toString('base64');
const filename = req.file.originalname;
// Queue import job
const job = await QueueService.queueImport(auth.projectId!, csvData, filename);
return res.status(202).json({
message: 'Import queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import',
});
}
}
/**
* GET /contacts/import/:jobId
* Get import job status
*/
@Get('import/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getImportJobStatus(jobId);
if (!status) {
return res.status(404).json({error: 'Import job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get import status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status',
});
}
}
/**
* GET /contacts/fields/:field/usage
* Check if a field is used in segments/campaigns and get usage statistics
* Returns information about where the field is used and whether it can be safely deleted
*/
@Get('fields/:field/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const field = req.params.field;
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
return res.status(200).json(usage);
} catch (error) {
signale.error('[CONTACTS] Failed to get field usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field usage',
});
}
}
/**
* DELETE /contacts/fields/:field
* Delete a custom field from all contacts
* Only works if the field is not used in any segments or campaigns
*/
@Delete('fields/:field')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const field = req.params.field;
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const result = await ContactService.deleteField(auth.projectId!, field);
return res.status(200).json(result);
} catch (error) {
signale.error('[CONTACTS] Failed to delete field:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete field',
});
}
}
/**
* POST /contacts/bulk-subscribe
* Queue bulk subscribe operation
*/
@Post('bulk-subscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
// Validate limit
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
return res.status(202).json({
message: 'Bulk subscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
});
}
}
/**
* POST /contacts/bulk-unsubscribe
* Queue bulk unsubscribe operation
*/
@Post('bulk-unsubscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
return res.status(202).json({
message: 'Bulk unsubscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
});
}
}
/**
* POST /contacts/bulk-delete
* Queue bulk delete operation
*/
@Post('bulk-delete')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
return res.status(202).json({
message: 'Bulk delete queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
});
}
}
/**
* GET /contacts/bulk/:jobId
* Get bulk action job status
*/
@Get('bulk/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getBulkActionJobStatus(jobId);
if (!status) {
return res.status(404).json({error: 'Bulk action job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get bulk action status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
});
}
}
}
+178
View File
@@ -0,0 +1,178 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
export class Domains {
/**
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have access');
}
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains);
}
/**
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) {
throw new NotFound('User authentication required');
}
// Verify user has admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission');
}
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) {
// If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) {
return res.status(400).json({
error: `This domain is already linked to project "${ownershipCheck.projectName}". You are a member of that project, so you can use the domain from there.`,
});
}
// Domain exists but user is not a member - deny access
return res.status(403).json({
error: 'This domain is already linked to another project. Only members of that project can use this domain.',
});
}
try {
const newDomain = await DomainService.addDomain(projectId, domain);
await redis.del(Keys.Domain.project(projectId));
return res.status(201).json(newDomain);
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({error: error.message});
}
throw error;
}
}
/**
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// Verify user has access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have access');
}
const verificationStatus = await DomainService.checkVerification(id);
// Invalidate cache if status changed
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json(verificationStatus);
}
/**
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// Verify user has admin access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have permission');
}
await DomainService.removeDomain(id);
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json({success: true});
}
}
+155
View File
@@ -0,0 +1,155 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('events')
export class Events {
/**
* POST /events/track
* Track a custom event (can trigger workflows)
*/
@Post('track')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {name, contactId, emailId, data} = req.body;
if (!name) {
return res.status(400).json({error: 'Event name is required'});
}
const event = await EventService.trackEvent(auth.projectId!, name, contactId, emailId, data);
return res.status(201).json(event);
}
/**
* GET /events
* List events for the project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100;
const events = await EventService.getProjectEvents(auth.projectId!, eventName, limit);
return res.status(200).json({events});
}
/**
* GET /events/stats
* Get event statistics
*/
@Get('stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
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 stats = await EventService.getEventStats(auth.projectId!, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /events/contact/:contactId
* Get events for a specific contact
*/
@Get('contact/:contactId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const events = await EventService.getContactEvents(auth.projectId!, contactId, limit);
return res.status(200).json({events});
}
/**
* GET /events/names
* Get unique event names for the project
*/
@Get('names')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
return res.status(200).json({eventNames});
}
/**
* GET /events/:eventName/usage
* Check if an event is used in segments/workflows and get usage statistics
* Returns information about where the event is used and whether it can be safely deleted
*/
@Get(':eventName/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.params.eventName;
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
try {
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
return res.status(200).json(usage);
} catch (error) {
signale.error('[EVENTS] Failed to get event usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get event usage',
});
}
}
/**
* DELETE /events/:eventName
* Delete all events with a specific name
* Only works if the event is not used in any segments or workflows
*/
@Delete(':eventName')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.params.eventName;
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
try {
const result = await EventService.deleteEvent(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
signale.error('[EVENTS] Failed to delete event:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete event',
});
}
}
}
+108
View File
@@ -0,0 +1,108 @@
import {Controller, Get} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GITHUB_OAUTH_CLIENT,
GITHUB_OAUTH_ENABLED,
GITHUB_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
import {CatchAsync} from '../../utils/asyncHandler.js';
@Controller('github')
export class Github {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const OAUTH_QS = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
redirect_uri: `${API_URI}/oauth/github/callback`,
response_type: 'code',
scope: 'user:email',
});
return res.redirect(`https://github.com/login/oauth/authorize?${OAUTH_QS.toString()}`);
}
@Get('callback')
@CatchAsync
public async callback(req: Request, res: Response, _next: NextFunction) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
client_secret: GITHUB_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/github/callback`,
});
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
body: data,
}).then(res => res.json());
if (!tokenResponse.access_token || !tokenResponse.token_type) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
}
const emails = await fetch(`https://api.github.com/user/emails`, {
headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
}).then(res => res.json());
if (!Array.isArray(emails) || emails.length === 0) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
}
const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
}
const email = primaryEmail.email;
let user = await UserService.email(email);
let isNewUser = false;
if (!user) {
user = await prisma.user.create({
data: {
email,
type: 'GITHUB_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
}
if (user.type !== 'GITHUB_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'GitHub');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+98
View File
@@ -0,0 +1,98 @@
import {Controller, Get} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
GOOGLE_OAUTH_CLIENT,
GOOGLE_OAUTH_ENABLED,
GOOGLE_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
import {CatchAsync} from '../../utils/asyncHandler.js';
@Controller('google')
export class Google {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
return res.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.email&access_type=offline&include_granted_scopes=true&prompt=select_account&response_type=code&redirect_uri=${API_URI}/oauth/google/callback&client_id=${GOOGLE_OAUTH_CLIENT}`,
);
}
@Get('callback')
@CatchAsync
public async callback(req: Request, res: Response, _next: NextFunction) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT,
client_secret: GOOGLE_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/google/callback`,
grant_type: 'authorization_code',
});
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: data,
}).then(res => res.json());
if (!tokenResponse.access_token) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
}
const userInfoResponse = await fetch(
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
).then(res => res.json());
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
}
const email = userInfoResponse.email;
let user = await UserService.email(email);
let isNewUser = false;
if (!user) {
user = await prisma.user.create({
data: {
email,
type: 'GOOGLE_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
}
if (user.type !== 'GOOGLE_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'Google');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+8
View File
@@ -0,0 +1,8 @@
import {ChildControllers, Controller} from '@overnightjs/core';
import {Github} from './Github.js';
import {Google} from './Google.js';
@Controller('oauth')
@ChildControllers([new Google(), new Github()])
export class Oauth {}
+416
View File
@@ -0,0 +1,416 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('projects')
export class Projects {
/**
* Get project setup state for dashboard quick start
* GET /projects/:id/setup-state
*/
@Get(':id/setup-state')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Get project with relevant data
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
_count: {
select: {
contacts: true,
domains: {
where: {verified: true},
},
workflows: {
where: {enabled: true},
},
},
},
},
});
if (!project) {
throw new HttpException(404, 'Project not found');
}
// Get last sent campaign
const lastCampaign = await prisma.campaign.findFirst({
where: {
projectId: id,
status: 'SENT',
sentAt: {not: null},
},
orderBy: {
sentAt: 'desc',
},
select: {
sentAt: true,
},
});
return res.json({
success: true,
data: {
hasSubscription: !!project.subscription,
hasVerifiedDomain: project._count.domains > 0,
contactCount: project._count.contacts,
lastCampaignSentAt: lastCampaign?.sentAt || null,
hasEnabledWorkflow: project._count.workflows > 0,
},
});
}
/**
* Get project security metrics
* GET /projects/:id/security
*/
@Get(':id/security')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.json({
success: true,
data: metrics,
});
}
/**
* Get all members of a project
* GET /projects/:id/members
*/
@Get(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
// Get all members of the project
const members = await prisma.membership.findMany({
where: {
projectId: id,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
return res.json({
success: true,
data: members.map(m => ({
userId: m.user.id,
email: m.user.email,
role: m.role,
})),
});
}
/**
* Add a member to a project by email
* POST /projects/:id/members
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/
@Post(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
// Validate and parse request body
const parseResult = MembershipSchemas.addMember.safeParse(req.body);
if (!parseResult.success) {
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
}
const {email, role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can add members');
}
// Find user by email
const userToAdd = await prisma.user.findUnique({
where: {email: email.toLowerCase()},
select: {id: true, email: true},
});
if (!userToAdd) {
throw new HttpException(404, 'User with this email does not have an account');
}
// Check if user is already a member
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId: userToAdd.id,
projectId: id,
},
},
});
if (existingMembership) {
throw new HttpException(409, 'User is already a member of this project');
}
// Create membership
const newMembership = await prisma.membership.create({
data: {
userId: userToAdd.id,
projectId: id,
role,
},
});
return res.json({
success: true,
data: {
userId: userToAdd.id,
email: userToAdd.email,
role: newMembership.role,
},
});
}
/**
* Update a member's role
* PATCH /projects/:id/members/:userId
* Body: { role: 'ADMIN' | 'MEMBER' }
*/
@Patch(':id/members/:userId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id, userId} = req.params;
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
if (!userId) {
throw new HttpException(400, 'User ID is required');
}
// Validate and parse request body
const parseResult = MembershipSchemas.updateRole.safeParse(req.body);
if (!parseResult.success) {
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
}
const {role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can update member roles');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot change OWNER role
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot change the role of the project owner');
}
// Get user info
const user = await prisma.user.findUnique({
where: {id: userId},
select: {id: true, email: true},
});
if (!user) {
throw new HttpException(404, 'User not found');
}
// Update role
await prisma.membership.update({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
data: {role},
});
return res.json({
success: true,
data: {
userId: user.id,
email: user.email,
role,
},
});
}
/**
* Remove a member from a project
* DELETE /projects/:id/members/:userId
*/
@Delete(':id/members/:userId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id, userId} = req.params;
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
if (!userId) {
throw new HttpException(400, 'User ID is required');
}
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can remove members');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot remove OWNER
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot remove the project owner');
}
// Cannot remove yourself
if (userId === auth.userId) {
throw new HttpException(403, 'You cannot remove yourself from the project');
}
// Delete membership
await prisma.membership.delete({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
return res.json({
success: true,
data: {message: 'Member removed successfully'},
});
}
}
+186
View File
@@ -0,0 +1,186 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('segments')
export class Segments {
/**
* GET /segments
* List all segments for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segments = await SegmentService.list(auth.projectId!);
return res.status(200).json(segments);
}
/**
* GET /segments/:id
* Get a specific segment by ID with member count
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const segment = await SegmentService.get(auth.projectId!, segmentId);
return res.status(200).json(segment);
}
/**
* GET /segments/:id/contacts
* Get contacts that match a segment's filters
*/
@Get(':id/contacts')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.getContacts(auth.projectId!, segmentId, page, pageSize);
return res.status(200).json(result);
}
/**
* POST /segments
* Create a new segment
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {name, description, condition, trackMembership} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!condition || typeof condition !== 'object') {
return res.status(400).json({error: 'Condition is required and must be an object'});
}
const segment = await SegmentService.create(auth.projectId!, {
name,
description,
condition,
trackMembership,
});
return res.status(201).json(segment);
}
/**
* PATCH /segments/:id
* Update a segment
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
if (condition !== undefined && typeof condition !== 'object') {
return res.status(400).json({error: 'Condition must be an object'});
}
const segment = await SegmentService.update(auth.projectId!, segmentId, {
name,
description,
condition,
trackMembership,
});
return res.status(200).json(segment);
}
/**
* DELETE /segments/:id
* Delete a segment
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
await SegmentService.delete(auth.projectId!, segmentId);
return res.status(204).send();
}
/**
* POST /segments/:id/compute
* Recompute segment membership for all contacts
*/
@Post(':id/compute')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.computeMembership(auth.projectId!, segmentId);
return res.status(200).json(result);
}
/**
* POST /segments/:id/refresh
* Refresh segment member count
*/
@Post(':id/refresh')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const memberCount = await SegmentService.refreshMemberCount(auth.projectId!, segmentId);
return res.status(200).json({memberCount});
}
}
+190
View File
@@ -0,0 +1,190 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('templates')
export class Templates {
/**
* GET /templates
* List all templates for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const type = req.query.type as TemplateType | undefined;
const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type);
return res.status(200).json(result);
}
/**
* GET /templates/:id
* Get a specific template by ID
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.get(auth.projectId!, templateId);
return res.status(200).json(template);
}
/**
* POST /templates
* Create a new template
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!subject) {
return res.status(400).json({error: 'Subject is required'});
}
if (!body) {
return res.status(400).json({error: 'Body is required'});
}
if (!from) {
return res.status(400).json({error: 'From address is required'});
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId!);
const template = await TemplateService.create(auth.projectId!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(201).json(template);
}
/**
* PATCH /templates/:id
* Update a template
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId!);
}
const template = await TemplateService.update(auth.projectId!, templateId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(200).json(template);
}
/**
* DELETE /templates/:id
* Delete a template
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
await TemplateService.delete(auth.projectId!, templateId);
return res.status(204).send();
}
/**
* POST /templates/:id/duplicate
* Duplicate a template
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.duplicate(auth.projectId!, templateId);
return res.status(201).json(template);
}
/**
* GET /templates/:id/usage
* Get template usage statistics
*/
@Get(':id/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const usage = await TemplateService.getUsage(auth.projectId!, templateId);
return res.status(200).json(usage);
}
}
+76
View File
@@ -0,0 +1,76 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
}
},
});
@Controller('uploads')
export class Uploads {
/**
* POST /uploads/image
* Upload an image file to S3/Minio
*/
@Post('image')
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
try {
if (!S3Service.isS3Enabled()) {
return res.status(503).json({
error: 'File uploads are not enabled. Please configure S3 storage.',
});
}
if (!req.file) {
return res.status(400).json({
error: 'No image file provided',
});
}
// Upload file to S3/Minio
const result = await S3Service.uploadFile({
file: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
projectId: auth.projectId!,
});
return res.status(200).json({
url: result.url,
key: result.key,
filename: req.file.originalname,
contentType: req.file.mimetype,
size: req.file.size,
});
} catch (error) {
signale.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image',
});
}
}
}
+850
View File
@@ -0,0 +1,850 @@
import {randomBytes} from 'node:crypto';
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import signale from 'signale';
@Controller('users')
export class Users {
@Get('@me')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
const me = await UserService.id(auth.userId);
if (!me) {
throw new NotAuthenticated();
}
return res.status(200).json({id: me.id, email: me.email});
}
@Get('@me/projects')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
const projects = await UserService.projects(auth.userId);
return res.status(200).json(projects);
}
@Post('@me/projects')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
if (!auth.userId) {
throw new NotAuthenticated();
}
// Check if user is a member of any disabled project
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
ErrorCode.PROJECT_DISABLED,
);
}
const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Create the project
const project = await prisma.project.create({
data: {
name,
public: publicKey,
secret: secretKey,
members: {
create: {
userId: auth.userId,
role: 'OWNER',
},
},
},
});
// Send notification about project creation
await NtfyService.notifyProjectCreated(project.name, project.id, auth.userId);
return res.status(201).json(project);
}
@Patch('@me/projects/:id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update it');
}
// Update the project
const project = await prisma.project.update({
where: {id},
data,
});
return res.status(200).json(project);
}
@Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to regenerate keys');
}
// Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Update the project with new keys
const project = await prisma.project.update({
where: {id},
data: {
public: publicKey,
secret: secretKey,
},
select: {
id: true,
name: true,
public: true,
secret: true,
createdAt: true,
updatedAt: true,
disabled: true,
customer: true,
subscription: true,
},
});
// Send notification about API key regeneration
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
return res.status(200).json(project);
}
@Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// If project already has a subscription, return error
if (project.subscription) {
return res.status(400).json({error: 'Project already has a subscription'});
}
// Build line items for subscription
const lineItems = [];
// Add one-time onboarding fee if configured
if (STRIPE_PRICE_ONBOARDING) {
lineItems.push({price: STRIPE_PRICE_ONBOARDING, quantity: 1});
}
// Add metered pricing for pay-per-email (required)
if (!STRIPE_PRICE_EMAIL_USAGE) {
return res.status(500).json({error: 'Usage-based pricing not configured. Set STRIPE_PRICE_EMAIL_USAGE.'});
}
lineItems.push({price: STRIPE_PRICE_EMAIL_USAGE}); // No quantity for metered items
// Calculate billing cycle anchor to first day of next month
// This ensures all subscriptions renew on the 1st of each month
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
// Validate currency if provided
let checkoutCurrency: string | undefined;
if (currency && typeof currency === 'string') {
const validCurrencies = ['usd', 'eur', 'gbp'];
if (validCurrencies.includes(currency.toLowerCase())) {
checkoutCurrency = currency.toLowerCase();
} else {
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
}
}
// Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
success_url: `${DASHBOARD_URI}/settings?tab=billing&success=true`,
cancel_url: `${DASHBOARD_URI}/settings?tab=billing&canceled=true`,
});
return res.status(200).json({url: session.url});
}
@Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// Project must have a customer ID to access billing portal
if (!project.customer) {
return res.status(400).json({error: 'No customer found for this project'});
}
// Create billing portal session
const session = await stripe.billingPortal.sessions.create({
customer: project.customer,
return_url: `${DASHBOARD_URI}/settings?tab=billing`,
});
return res.status(200).json({url: session.url});
}
@Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing limits');
}
// Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
const data = BillingLimitSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
// Get the project with current limits
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
// Require active subscription to set billing limits
if (!project.subscription) {
return res.status(400).json({error: 'An active subscription is required to set billing limits'});
}
// Update billing limits
await prisma.project.update({
where: {id},
data: {
billingLimitWorkflows: data.workflows,
billingLimitCampaigns: data.campaigns,
billingLimitTransactional: data.transactional,
},
});
// Clear notification cache keys for limits that changed
// This allows new warning/limit emails to be sent when the new limits are reached
await BillingLimitService.clearNotificationCacheForChangedLimits(
id,
{
workflows: project.billingLimitWorkflows,
campaigns: project.billingLimitCampaigns,
transactional: project.billingLimitTransactional,
},
data,
);
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
subscription: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer || !project.subscription) {
return res.status(400).json({error: 'No active subscription found'});
}
// Get the current billing period (start of current month to now)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Get upcoming invoice to see costs and usage
let upcomingInvoice = null;
let totalUsage = 0;
try {
upcomingInvoice = (await stripe.invoices.createPreview({
customer: project.customer,
subscription: project.subscription,
})) as any;
// Extract metered usage from invoice line items
if (upcomingInvoice && upcomingInvoice.lines && upcomingInvoice.lines.data) {
// Since we only have one meter (email usage), we can safely use the first line item
// The invoice preview doesn't expand the price object, so we can't match by price ID
const meteredLine = upcomingInvoice.lines.data[0];
if (meteredLine && meteredLine.quantity) {
totalUsage = meteredLine.quantity;
}
}
} catch (error) {
// No upcoming invoice yet or error retrieving it
// This is normal for new subscriptions or if there's no usage yet
signale.error('[BILLING] Error retrieving upcoming invoice:', error);
}
// Get customer to retrieve balance (credits)
const customer = await stripe.customers.retrieve(project.customer);
let credits = null;
if (!customer.deleted) {
// Stripe stores balance in cents as a negative number (credit) or positive (debit)
// A negative balance means the customer has credits
const balance = customer.balance || 0;
const hasCredits = balance < 0;
const creditAmount = hasCredits ? Math.abs(balance) : 0;
credits = {
balance,
hasCredits,
creditAmount,
currency: customer.currency || 'usd',
};
}
const responseData = {
period: {
start: startOfMonth.toISOString(),
end: now.toISOString(),
},
usage: {
total: totalUsage,
records: [], // Deprecated in favor of invoice line items
},
credits,
upcomingInvoice: upcomingInvoice
? {
amountDue: upcomingInvoice.amount_due,
currency: upcomingInvoice.currency,
periodStart: upcomingInvoice.period_start
? new Date(upcomingInvoice.period_start * 1000).toISOString()
: startOfMonth.toISOString(),
periodEnd: upcomingInvoice.period_end
? new Date(upcomingInvoice.period_end * 1000).toISOString()
: now.toISOString(),
subtotal: upcomingInvoice.subtotal ?? 0,
total: upcomingInvoice.total ?? 0,
}
: null,
};
return res.status(200).json(responseData);
}
@Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
// Get the project
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer) {
return res.status(400).json({error: 'No customer found'});
}
// Get all invoices for this customer
const invoices = await stripe.invoices.list({
customer: project.customer,
limit: 100,
});
// Check for unpaid invoices
const unpaidInvoices = invoices.data.filter(
invoice => invoice.status === 'open' || invoice.status === 'uncollectible',
);
return res.status(200).json({
invoices: invoices.data.map(invoice => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: new Date(invoice.created * 1000).toISOString(),
periodStart: invoice.period_start ? new Date(invoice.period_start * 1000).toISOString() : null,
periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
subtotal: invoice.subtotal,
total: invoice.total,
paid: invoice.status === 'paid',
})),
hasUnpaidInvoices: unpaidInvoices.length > 0,
unpaidInvoices: unpaidInvoices.map(invoice => ({
id: invoice.id,
number: invoice.number,
amountDue: invoice.amount_due,
currency: invoice.currency,
dueDate: invoice.due_date ? new Date(invoice.due_date * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
})),
});
}
@Get('@me/projects/:id/security')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view security metrics');
}
// Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.status(200).json(metrics);
}
@Post('@me/projects/:id/reset')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to reset it');
}
// Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id);
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
ErrorCode.PROJECT_DISABLED,
);
}
// Delete all project data in a transaction
await prisma.$transaction(async tx => {
// Delete all emails
await tx.email.deleteMany({
where: {projectId: id},
});
// Delete all events
await tx.event.deleteMany({
where: {projectId: id},
});
// Delete all campaigns
await tx.campaign.deleteMany({
where: {projectId: id},
});
// Delete all workflows
await tx.workflow.deleteMany({
where: {projectId: id},
});
// Delete all segments
await tx.segment.deleteMany({
where: {projectId: id},
});
// Delete all contacts
await tx.contact.deleteMany({
where: {projectId: id},
});
// Delete all templates
await tx.template.deleteMany({
where: {projectId: id},
});
// Delete all API requests
await tx.apiRequest.deleteMany({
where: {projectId: id},
});
});
return res.status(200).json({success: true, message: 'Project reset successfully'});
}
@Delete('@me/projects/:id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has owner or admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['OWNER', 'ADMIN'],
},
},
});
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
const project = await prisma.project.findUnique({
where: {id},
select: {
name: true,
subscription: true,
customer: true,
disabled: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
// Check if project is disabled - block delete operation
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
ErrorCode.PROJECT_DISABLED,
);
}
// If project has an active subscription, cancel it first
if (STRIPE_ENABLED && stripe && project.subscription) {
try {
await stripe.subscriptions.cancel(project.subscription);
} catch (error) {
// Log but don't fail if subscription cancellation fails
signale.error('Failed to cancel subscription:', error);
}
}
// Delete the project (cascading deletes will handle related data)
await prisma.project.delete({
where: {id},
});
// Send notification about project deletion
await NtfyService.notifyProjectDeleted(project.name, id, auth.userId);
return res.status(200).json({success: true, message: 'Project deleted successfully'});
}
}
+399
View File
@@ -0,0 +1,399 @@
import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import signale from 'signale';
import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {EventService} from '../services/EventService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
/**
* Webhooks Controller
* Handles incoming webhooks from external services (AWS SNS/SES)
*/
@Controller('webhooks')
export class Webhooks {
/**
* Receive SNS webhook notifications from AWS SES
* Handles email events: delivery, open, click, bounce, complaint
*/
@Post('sns')
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Handle SNS subscription confirmation FIRST (before parsing Message field)
if (req.body.Type === 'SubscriptionConfirmation') {
signale.info('SNS Subscription Confirmation received');
signale.info('Subscribe URL:', req.body.SubscribeURL);
// Automatically confirm the subscription
try {
const confirmResponse = await fetch(req.body.SubscribeURL);
if (confirmResponse.ok) {
signale.success('SNS subscription confirmed successfully');
return res.status(200).json({
success: true,
message: 'Subscription confirmed',
});
} else {
signale.error('Failed to confirm SNS subscription:', confirmResponse.statusText);
return res.status(200).json({
success: false,
message: 'Failed to confirm subscription',
subscribeURL: req.body.SubscribeURL,
});
}
} catch (confirmError) {
signale.error('Error confirming SNS subscription:', confirmError);
return res.status(200).json({
success: false,
message: 'Error confirming subscription',
subscribeURL: req.body.SubscribeURL,
});
}
}
// Handle SNS notification messages - parse the Message field
if (req.body.Type !== 'Notification') {
signale.warn('[WEBHOOK] Unknown SNS message type:', req.body.Type);
return res.status(200).json({success: false, message: 'Unknown message type'});
}
// Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message);
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId;
if (!messageId) {
signale.warn('[WEBHOOK] No messageId found in SNS notification');
return res.status(400).json({success: false, error: 'No messageId found'});
}
// Look up email by SES messageId
const email = await prisma.email.findUnique({
where: {messageId},
include: {
contact: true,
project: true,
},
});
if (!email) {
signale.warn(`[WEBHOOK] Email not found for messageId: ${messageId}`);
return res.status(404).json({success: false, error: 'Email not found'});
}
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
const eventName = `email.${eventType.toLowerCase()}`;
// Base event data with email metadata
const baseEventData = {
subject: email.subject,
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
};
let eventData: Record<string, unknown> = baseEventData;
// Process event based on type
switch (eventType) {
case 'Delivery':
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
eventData = {
...baseEventData,
deliveredAt: now.toISOString(),
};
break;
case 'Open':
signale.success(`[WEBHOOK] Open received for ${email.contact.email} from ${email.project.name}`);
// Only set openedAt on first open
if (!email.openedAt) {
updateData.openedAt = now;
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
eventData = {
...baseEventData,
openedAt: email.openedAt?.toISOString() || now.toISOString(),
opens: (email.opens || 0) + 1,
isFirstOpen: !email.openedAt,
};
break;
case 'Click': {
signale.success(`[WEBHOOK] Click received for ${email.contact.email} from ${email.project.name}`);
const clickedLink = body.click?.link;
// Only set clickedAt on first click
if (!email.clickedAt) {
updateData.clickedAt = now;
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
eventData = {
...baseEventData,
link: clickedLink,
clickedAt: email.clickedAt?.toISOString() || now.toISOString(),
clicks: (email.clicks || 0) + 1,
isFirstClick: !email.clickedAt,
};
break;
}
case 'Bounce':
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType: body.bounce?.bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about bounce
await NtfyService.notifyEmailBounce(
email.project.name,
email.projectId,
email.contact.email,
body.bounce?.bounceType,
);
break;
case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact on complaint
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
complainedAt: now.toISOString(),
};
// Send notification about complaint
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
break;
default:
signale.warn(`[WEBHOOK] Unknown event type: ${eventType}`);
return res.status(200).json({success: true});
}
// Update email with new status and timestamps
await prisma.email.update({
where: {id: email.id},
data: updateData,
});
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events
if (eventType === 'Bounce' || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
}
signale.success(`[WEBHOOK] Processed ${eventType} event for email ${email.id}`);
return res.status(200).json({success: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing SNS webhook:', error);
// Always return 200 to prevent SNS from retrying
return res.status(200).json({success: true});
}
}
/**
* Receive Stripe webhook notifications
* Handles subscription and payment events: checkout.session.completed, invoice.paid, etc.
*/
@Post('incoming/stripe')
@CatchAsync
public async receiveStripeWebhook(req: Request, res: Response) {
// Return 404 if billing is disabled
if (!STRIPE_ENABLED || !stripe) {
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
return res.status(404).json({success: false, error: 'Billing is disabled'});
}
try {
const sig = req.headers['stripe-signature'];
if (!sig) {
signale.warn('[WEBHOOK] Missing Stripe signature header');
return res.status(400).json({success: false, error: 'Missing signature'});
}
// Verify webhook signature using raw body
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
signale.error('[WEBHOOK] Stripe signature verification failed:', err);
return res.status(400).json({success: false, error: 'Invalid signature'});
}
signale.info(`[WEBHOOK] Received Stripe event: ${event.type}`);
// Handle different event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const projectId = session.client_reference_id; // Assuming project ID is passed as reference
if (!projectId) {
signale.warn('[WEBHOOK] No client_reference_id in checkout session');
break;
}
// Update project with customer and subscription IDs
const updatedProject = await prisma.project.update({
where: {id: projectId},
data: {
customer: customerId,
subscription: subscriptionId,
},
});
// Update Stripe customer name to match project name and add credit for onboarding fee
await stripe.customers.update(customerId, {
name: updatedProject.name,
balance: -100,
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
// Send notification about subscription started
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
// Send notification about payment failure
await NtfyService.notifyPaymentFailed(project.name, project.id);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
// Clear subscription from project
await prisma.project.update({
where: {id: project.id},
data: {
subscription: null,
},
});
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Send notification about subscription cancellation
await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
signale.info(`[WEBHOOK] Subscription updated for project ${project.name} (${project.id})`);
signale.info(
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
);
// Send notification about subscription update
await NtfyService.notifySubscriptionUpdated(project.name, project.id);
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
break;
}
return res.status(200).json({success: true, received: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing Stripe webhook:', error);
return res.status(400).json({success: false, error: 'Webhook processing failed'});
}
}
}
+394
View File
@@ -0,0 +1,394 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('workflows')
export class Workflows {
/**
* GET /workflows
* List all workflows for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const result = await WorkflowService.list(auth.projectId!, page, pageSize, search);
return res.status(200).json(result);
}
/**
* GET /workflows/fields
* Get all available fields for workflow conditions (contact fields + event fields)
* Query param: eventName - Optional event name to filter event fields
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/
@Get('fields')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.query.eventName as string | undefined;
try {
const result = await WorkflowService.getAvailableFields(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
signale.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /workflows/:id
* Get a specific workflow with all steps and transitions
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.get(auth.projectId!, workflowId);
return res.status(200).json(workflow);
}
/**
* POST /workflows
* Create a new workflow
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
const workflow = await WorkflowService.create(auth.projectId!, {
name,
description,
eventName,
enabled,
allowReentry,
});
return res.status(201).json(workflow);
}
/**
* PATCH /workflows/:id
* Update a workflow
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.update(auth.projectId!, workflowId, {
name,
description,
triggerType,
triggerConfig,
enabled,
allowReentry,
});
return res.status(200).json(workflow);
}
/**
* DELETE /workflows/:id
* Delete a workflow
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
await WorkflowService.delete(auth.projectId!, workflowId);
return res.status(204).send();
}
/**
* POST /workflows/:id/steps
* Add a step to a workflow
*/
@Post(':id/steps')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!type || !name || !position || !config) {
return res.status(400).json({error: 'Type, name, position, and config are required'});
}
const step = await WorkflowService.addStep(auth.projectId!, workflowId, {
type,
name,
position,
config,
templateId,
autoConnect,
});
return res.status(201).json(step);
}
/**
* PATCH /workflows/:id/steps/:stepId
* Update a workflow step
*/
@Patch(':id/steps/:stepId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
const step = await WorkflowService.updateStep(auth.projectId!, workflowId, stepId, {
name,
position,
config,
templateId,
});
return res.status(200).json(step);
}
/**
* DELETE /workflows/:id/steps/:stepId
* Delete a workflow step
*/
@Delete(':id/steps/:stepId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const stepId = req.params.stepId;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
return res.status(204).send();
}
/**
* POST /workflows/:id/transitions
* Create a transition between steps
*/
@Post(':id/transitions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!fromStepId || !toStepId) {
return res.status(400).json({error: 'From step ID and to step ID are required'});
}
const transition = await WorkflowService.createTransition(auth.projectId!, workflowId, {
fromStepId,
toStepId,
condition,
priority,
});
return res.status(201).json(transition);
}
/**
* DELETE /workflows/:id/transitions/:transitionId
* Delete a transition
*/
@Delete(':id/transitions/:transitionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const transitionId = req.params.transitionId;
if (!workflowId || !transitionId) {
return res.status(400).json({error: 'Workflow ID and Transition ID are required'});
}
await WorkflowService.deleteTransition(auth.projectId!, workflowId, transitionId);
return res.status(204).send();
}
/**
* POST /workflows/:id/executions
* Start a workflow execution for a contact
*/
@Post(':id/executions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const {contactId, context} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const execution = await WorkflowService.startExecution(auth.projectId!, workflowId, contactId, context);
return res.status(201).json(execution);
}
/**
* GET /workflows/:id/executions
* List executions for a workflow
*/
@Get(':id/executions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const status = req.query.status as WorkflowExecutionStatus | undefined;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.listExecutions(auth.projectId!, workflowId, page, pageSize, status);
return res.status(200).json(result);
}
/**
* GET /workflows/:id/executions/:executionId
* Get a specific execution with details
*/
@Get(':id/executions/:executionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.getExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
/**
* DELETE /workflows/:id/executions/:executionId
* Cancel a workflow execution
*/
@Delete(':id/executions/:executionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.cancelExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
/**
* POST /workflows/:id/executions/cancel-all
* Cancel all active executions for a workflow
*/
@Post(':id/executions/cancel-all')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.cancelAllExecutions(auth.projectId!, workflowId);
return res.status(200).json(result);
}
}
@@ -0,0 +1,222 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories} from '../../../../../test/helpers';
import {EventService} from '../../services/EventService';
import {WorkflowService} from '../../services/WorkflowService';
describe('Workflows Controller', () => {
let projectId: string;
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// GET /workflows/fields
// ========================================
describe('GET /workflows/fields', () => {
it('should return contact and event fields', async () => {
const contact = await factories.createContact({
projectId,
data: {
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
},
});
// Create email events with data
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Welcome',
from: 'hello@example.com',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
expect(responseBody.fields).toBeInstanceOf(Array);
expect(responseBody.count).toBeGreaterThan(0);
// Should include standard contact fields
expect(responseBody.fields).toContain('contact.email');
expect(responseBody.fields).toContain('contact.subscribed');
// Should include custom contact data fields
expect(responseBody.fields).toContain('contact.data.firstName');
expect(responseBody.fields).toContain('contact.data.lastName');
expect(responseBody.fields).toContain('contact.data.plan');
// Should include event fields
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.from');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.isFirstOpen');
});
it('should filter event fields by eventName query param', async () => {
const contact = await factories.createContact({projectId});
// Create email.opened events
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Test',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
// Create email.clicked events
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
subject: 'Test',
link: 'https://example.com',
clickedAt: new Date().toISOString(),
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
// Should include email.opened fields
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.isFirstOpen');
// Should NOT include email.clicked fields
expect(responseBody.fields).not.toContain('event.link');
expect(responseBody.fields).not.toContain('event.clickedAt');
});
it('should return fields in sorted order', async () => {
const contact = await factories.createContact({
projectId,
data: {
zebra: 'last',
apple: 'first',
},
});
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
zoo: 'value',
aardvark: 'value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
const fields = responseBody.fields as string[];
// Verify fields are sorted
const sortedFields = [...fields].sort();
expect(fields).toEqual(sortedFields);
});
it('should return empty event fields when no events exist', async () => {
const responseBody = await WorkflowService.getAvailableFields(projectId);
// Should still have contact fields
expect(responseBody.fields).toContain('contact.email');
expect(responseBody.fields).toContain('contact.subscribed');
// But no event fields
const eventFields = responseBody.fields.filter((f: string) => f.startsWith('event.'));
expect(eventFields).toHaveLength(0);
});
it('should only return fields for authenticated project', async () => {
// Create another project with events
const {project: otherProject} = await factories.createUserWithProject();
const otherContact = await factories.createContact({projectId: otherProject.id});
await EventService.trackEvent(otherProject.id, 'other.event', otherContact.id, undefined, {
secretField: 'secret value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
// Should not include fields from other project
expect(responseBody.fields).not.toContain('event.secretField');
});
it('should handle URL encoded event names', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
field1: 'value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
expect(responseBody.fields).toContain('event.field1');
});
});
// ========================================
// Integration Tests
// ========================================
describe('Workflow field discovery integration', () => {
it('should discover correct fields for email.sent events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
// Create email.sent event with actual structure
await EventService.trackEvent(projectId, 'email.sent', contact.id, email.id, {
subject: 'Welcome Email',
from: 'hello@example.com',
fromName: 'Example Team',
messageId: 'msg-123',
templateId: 'tpl-456',
campaignId: null,
sourceType: 'TRANSACTIONAL',
sentAt: new Date().toISOString(),
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.sent');
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.from');
expect(responseBody.fields).toContain('event.fromName');
expect(responseBody.fields).toContain('event.messageId');
expect(responseBody.fields).toContain('event.templateId');
expect(responseBody.fields).toContain('event.sourceType');
expect(responseBody.fields).toContain('event.sentAt');
});
it('should discover correct fields for email.opened events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
subject: 'Newsletter',
from: 'news@example.com',
fromName: 'Newsletter Team',
messageId: 'msg-456',
openedAt: new Date().toISOString(),
opens: 1,
isFirstOpen: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.opens');
expect(responseBody.fields).toContain('event.isFirstOpen');
});
it('should discover correct fields for email.clicked events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.clicked', contact.id, email.id, {
subject: 'Sale Announcement',
from: 'sales@example.com',
link: 'https://example.com/sale',
clickedAt: new Date().toISOString(),
clicks: 1,
isFirstClick: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.clicked');
expect(responseBody.fields).toContain('event.link');
expect(responseBody.fields).toContain('event.clickedAt');
expect(responseBody.fields).toContain('event.clicks');
expect(responseBody.fields).toContain('event.isFirstClick');
});
});
});
+3
View File
@@ -0,0 +1,3 @@
import {PrismaClient} from '@prisma/client';
export const prisma = new PrismaClient();
@@ -1,19 +1,12 @@
import Redis from 'ioredis';
import {REDIS_URL} from '../app/constants';
import signale from "signale";
import {Redis} from 'ioredis';
let redis: Redis;
try {
redis = new Redis(REDIS_URL);
const infoString = redis.info();
signale.info('Redis initialized: ', infoString);
} catch (error) {
signale.error('Failed to initialize Redis: ', error);
}
export {redis};
import {REDIS_URL} from '../app/constants.js';
export const redis = new Redis(REDIS_URL + '?family=0');
export const REDIS_ONE_MINUTE = 60;
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE;
export const TEN_MINUTES_IN_SECONDS = 60 * 10;
/**
* @param key The key for redis (use Keys#<type>)
@@ -34,3 +27,15 @@ export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds =
return recent;
}
export const cache = {
set<T>(key: string, value: T, seconds = REDIS_DEFAULT_EXPIRY): Promise<'OK' | null> {
return redis.set(key, JSON.stringify(value), 'EX', seconds);
},
incr(key: string): Promise<number> {
return redis.incr(key);
},
decr(key: string): Promise<number> {
return redis.decr(key);
},
};
+169
View File
@@ -0,0 +1,169 @@
/**
* Machine-readable error codes for programmatic error handling
* These codes allow API consumers to handle specific error types reliably
*/
export enum ErrorCode {
// Authentication & Authorization (401-403)
UNAUTHORIZED = 'UNAUTHORIZED',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
MISSING_AUTH = 'MISSING_AUTH',
INVALID_API_KEY = 'INVALID_API_KEY',
FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
CONTACT_NOT_FOUND = 'CONTACT_NOT_FOUND',
TEMPLATE_NOT_FOUND = 'TEMPLATE_NOT_FOUND',
CAMPAIGN_NOT_FOUND = 'CAMPAIGN_NOT_FOUND',
WORKFLOW_NOT_FOUND = 'WORKFLOW_NOT_FOUND',
CONFLICT = 'CONFLICT',
// Validation & Input Errors (400, 422)
BAD_REQUEST = 'BAD_REQUEST',
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_EMAIL = 'INVALID_EMAIL',
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
// Rate Limiting & Billing (429, 402)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
BILLING_LIMIT_EXCEEDED = 'BILLING_LIMIT_EXCEEDED',
UPGRADE_REQUIRED = 'UPGRADE_REQUIRED',
// Server Errors (500+)
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
}
/**
* Structured field-level validation error
*/
export interface FieldError {
field: string; // Dot-notation path (e.g., "email", "data.firstName")
message: string; // Human-readable error message
code: string; // Validation error code (e.g., "invalid_email", "too_short")
received?: unknown; // The invalid value that was provided
}
/**
* Base HTTP exception with support for error codes and structured data
*/
export class HttpException extends Error {
public constructor(
public readonly code: number,
message: string,
public readonly errorCode?: ErrorCode,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
}
}
/**
* Resource not found (404)
*/
export class NotFound extends HttpException {
/**
* Construct a new NotFound exception
* @param resource The type of resource that was not found
* @param id Optional resource identifier to include in the message
*/
public constructor(resource: string, id?: string) {
const message = id
? `${resource} with ID "${id}" was not found`
: `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = {
contact: ErrorCode.CONTACT_NOT_FOUND,
template: ErrorCode.TEMPLATE_NOT_FOUND,
campaign: ErrorCode.CAMPAIGN_NOT_FOUND,
workflow: ErrorCode.WORKFLOW_NOT_FOUND,
};
const errorCode = errorCodeMap[resource.toLowerCase()] || ErrorCode.RESOURCE_NOT_FOUND;
super(404, message, errorCode, {resource, id});
}
}
/**
* Forbidden - user is not allowed to perform this action (403)
*/
export class NotAllowed extends HttpException {
/**
* Construct a new NotAllowed exception
* @param msg Custom error message
* @param reason Optional reason for the forbidden action
*/
public constructor(msg = 'You are not allowed to perform this action', reason?: string) {
super(403, msg, ErrorCode.FORBIDDEN, reason ? {reason} : undefined);
}
}
/**
* Unauthorized - user needs to authenticate (401)
*/
export class NotAuthenticated extends HttpException {
public constructor(message = 'You need to be authenticated to do this', errorCode = ErrorCode.UNAUTHORIZED) {
super(401, message, errorCode);
}
}
/**
* Payment required - user needs to upgrade plan (402)
*/
export class NeedToUpgrade extends HttpException {
public constructor(msg = 'You need to upgrade your plan to do this') {
super(402, msg, ErrorCode.UPGRADE_REQUIRED);
}
}
/**
* Validation error with field-level details (422)
*/
export class ValidationError extends HttpException {
/**
* Construct a new ValidationError exception
* @param errors Array of field-level validation errors
* @param message Optional override for the main error message
*/
public constructor(
public readonly errors: FieldError[],
message = 'Validation failed',
) {
super(422, message, ErrorCode.VALIDATION_ERROR, {errors});
}
}
/**
* Conflict - resource already exists or state conflict (409)
*/
export class ConflictError extends HttpException {
public constructor(message: string, details?: Record<string, unknown>) {
super(409, message, ErrorCode.CONFLICT, details);
}
}
/**
* Rate limit exceeded (429)
*/
export class RateLimitError extends HttpException {
public constructor(message = 'Rate limit exceeded. Please try again later.', retryAfter?: number) {
super(429, message, ErrorCode.RATE_LIMIT_EXCEEDED, retryAfter ? {retryAfter} : undefined);
}
}
/**
* Bad request - generic client error (400)
*/
export class BadRequest extends HttpException {
public constructor(message: string, errorCode = ErrorCode.BAD_REQUEST, details?: Record<string, unknown>) {
super(400, message, errorCode, details);
}
}
@@ -0,0 +1,348 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService
vi.mock('../../services/MeterService.js', () => ({
MeterService: {
recordEmailSent: vi.fn().mockResolvedValue(undefined),
},
}));
describe('Email Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const _serviceMocks = createServiceMocks();
beforeEach(async () => {
const {project} = await factories.createUserWithProject({}, {tracking: TrackingMode.ENABLED});
projectId = project.id;
});
describe('Email Processing', () => {
it('should process a pending email', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
subject: 'Test Email',
body: '<p>Hello {{firstName}}</p>',
status: EmailStatus.PENDING,
});
// Mock the email processor logic
// In a real implementation, you would:
// 1. Create job tester
// 2. Mock SES service
// 3. Process the job
// 4. Verify status changes
// Simulate processing
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Simulate successful send
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
},
});
const processed = await prisma.email.findUnique({where: {id: email.id}});
expect(processed?.status).toBe(EmailStatus.SENT);
expect(processed?.sentAt).toBeDefined();
});
it('should skip emails that are not pending', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.SENT, // Already sent
});
// Processor should skip this email
const shouldProcess = email.status === EmailStatus.PENDING;
expect(shouldProcess).toBe(false);
});
it('should fail email if project is disabled', async () => {
// Create project with disabled flag
const {project: disabledProject} = await factories.createUserWithProject({}, {disabled: true});
const contact = await factories.createContact({projectId: disabledProject.id});
const email = await factories.createEmail(disabledProject.id, contact.id, {
status: EmailStatus.PENDING,
});
// Verify project is disabled
const project = await prisma.project.findUnique({
where: {id: disabledProject.id},
});
expect(project?.disabled).toBe(true);
// Processor should fail this email
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe('Project is disabled');
});
it('should handle campaign emails', async () => {
const contact = await factories.createContact({projectId});
const campaign = await factories.createCampaign({projectId});
const email = await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
});
expect(email.campaignId).toBe(campaign.id);
// Process the email
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
const sent = await prisma.email.findUnique({where: {id: email.id}});
expect(sent?.status).toBe(EmailStatus.SENT);
});
it('should handle transactional emails without unsubscribe', async () => {
const contact = await factories.createContact({projectId});
const template = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
await factories.createEmail(projectId, contact.id, {
templateId: template.id,
status: EmailStatus.PENDING,
});
expect(template.type).toBe('TRANSACTIONAL');
});
});
describe('Email Status Transitions', () => {
it('should transition PENDING -> SENDING -> SENT', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
expect(email.status).toBe(EmailStatus.PENDING);
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
let updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENDING);
// Transition to SENT
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENT);
expect(updated?.sentAt).toBeDefined();
});
it('should handle PENDING -> SENDING -> FAILED', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Fail with error
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'SES send failed: Invalid email address',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toContain('SES send failed');
});
});
describe('Batch Processing', () => {
it('should handle multiple emails from a campaign', async () => {
const campaign = await factories.createCampaign({projectId});
const contacts = await factories.createContacts(projectId, 10);
// Create emails for all contacts
const emails = await Promise.all(
contacts.map(contact =>
factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
}),
),
);
expect(emails).toHaveLength(10);
expect(emails.every(e => e.campaignId === campaign.id)).toBe(true);
// Simulate processing all emails
await Promise.all(
emails.map(email =>
prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
}),
),
);
const processed = await prisma.email.findMany({
where: {campaignId: campaign.id},
});
expect(processed.every(e => e.status === EmailStatus.SENT)).toBe(true);
});
});
describe('Error Handling', () => {
it('should record error message on failure', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Simulate failure
const errorMessage = 'Failed to send: Rate limit exceeded';
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: errorMessage,
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe(errorMessage);
});
});
describe('Attachment Billing', () => {
it('should verify emails with attachments have attachment data', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test email with attachments</p>',
from: 'test@example.com',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
{
filename: 'document.pdf',
content: 'base64encodedcontent',
contentType: 'application/pdf',
},
] as unknown as Prisma.InputJsonValue,
},
});
// Create email without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Verify attachments are stored correctly
const emailWithAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithAttachments.id},
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
expect(emailWithAttachmentsData?.attachments).toBeDefined();
expect(Array.isArray(emailWithAttachmentsData?.attachments)).toBe(true);
expect(
Array.isArray(emailWithAttachmentsData?.attachments) ? emailWithAttachmentsData.attachments.length : 0,
).toBeGreaterThan(0);
expect(emailWithoutAttachmentsData?.attachments).toBeNull();
});
it('should verify attachment logic determines charging correctly', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test</p>',
from: 'test@example.com',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
},
include: {
project: true,
contact: true,
},
});
// Simulate the logic from email-processor.ts
const hasAttachments =
emailWithAttachments.attachments &&
Array.isArray(emailWithAttachments.attachments) &&
emailWithAttachments.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
// Verify logic correctly identifies attachments
expect(hasAttachments).toBe(true);
expect(emailCount).toBe(2);
// Test without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
const hasNoAttachments =
emailWithoutAttachmentsData?.attachments &&
Array.isArray(emailWithoutAttachmentsData.attachments) &&
emailWithoutAttachmentsData.attachments.length > 0;
const emailCountNoAttachments = hasNoAttachments ? 2 : 1;
expect(hasNoAttachments).toBeFalsy();
expect(emailCountNoAttachments).toBe(1);
});
});
});
@@ -0,0 +1,281 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
* Verifies that CSV imports preserve subscription status correctly
*/
describe('Contact Import - Subscription Status Preservation', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Existing contacts', () => {
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: 'existing-subscribed@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
// Verify subscription status unchanged
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'existing-unsubscribed@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
// Verify subscription status unchanged (should NOT be re-subscribed)
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'import-resubscribe@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import with explicit subscribed=true
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: 'import-unsubscribe@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import with explicit subscribed=false
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
describe('New contacts', () => {
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
const newEmail = 'new-import-default@example.com';
// Simulate import without subscribed column (undefined)
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
const newEmail = 'new-import-explicit-sub@example.com';
// Simulate import with explicit subscribed=true
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
expect(contact.subscribed).toBe(true);
});
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
const newEmail = 'new-import-explicit-unsub@example.com';
// Simulate import with explicit subscribed=false
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
expect(contact.subscribed).toBe(false);
});
});
describe('CSV parsing logic', () => {
it('should parse "true" string as boolean true', () => {
const subscribedValue = 'true';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "1" string as boolean true', () => {
const subscribedValue = '1';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "yes" string as boolean true', () => {
const subscribedValue = 'yes';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "false" string as boolean false', () => {
const subscribedValue = 'false';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "0" string as boolean false', () => {
const subscribedValue = '0';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "no" string as boolean false', () => {
const subscribedValue = 'no';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should handle empty string as undefined', () => {
const subscribedValue = '';
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
it('should handle undefined as undefined', () => {
const subscribedValue = undefined;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
});
describe('Data preservation', () => {
it('should preserve existing contact data while updating subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'preserve-data@example.com',
data: {
firstName: 'Original',
lastName: 'Name',
plan: 'pro',
},
});
// Update only subscription via import
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be updated
expect(updated?.subscribed).toBe(true);
// Original data should be preserved
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('Original');
expect(data?.lastName).toBe('Name');
expect(data?.plan).toBe('pro');
});
it('should merge new data while preserving subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'merge-data@example.com',
data: {
firstName: 'John',
plan: 'pro',
},
});
// Import new data without changing subscription
await ContactService.upsert(
projectId,
contact.email,
{
lastName: 'Doe',
company: 'Acme Inc',
},
undefined,
);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be unchanged
expect(updated?.subscribed).toBe(false);
// Data should be merged
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('John'); // Preserved
expect(data?.plan).toBe('pro'); // Preserved
expect(data?.lastName).toBe('Doe'); // New
expect(data?.company).toBe('Acme Inc'); // New
});
});
});
@@ -0,0 +1,227 @@
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const timeControl = createTimeControl();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(() => {
// Ensure time is always restored between tests
timeControl.restore();
});
describe('Campaign Scheduling', () => {
it('should execute campaign at scheduled time', async () => {
// Freeze time at a specific moment
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 1 hour from now
const scheduledTime = timeControl.helpers.relative(1, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
name: 'Scheduled Newsletter',
subject: 'Weekly Update',
});
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
expect(campaign.scheduledFor).toEqual(scheduledTime);
// Advance time to scheduled time
timeControl.advanceTo(scheduledTime);
// Verify we're at the right time
expect(timeControl.now()).toEqual(scheduledTime);
// At this point, the scheduled processor would pick up this campaign
// and change its status to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const updatedCampaign = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should not execute campaign before scheduled time', async () => {
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 2 hours from now
const scheduledTime = timeControl.helpers.relative(2, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
// Advance time by only 1 hour (before scheduled time)
timeControl.helpers.advanceHours(1);
// Campaign should still be scheduled
const stillScheduled = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
timeControl.restore();
});
it('should handle multiple scheduled campaigns at different times', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Create campaigns scheduled at different times
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(1, 'hour'), // 11:00
{name: 'Campaign 1'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(2, 'hour'), // 12:00
{name: 'Campaign 2'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(3, 'hour'), // 13:00
{name: 'Campaign 3'},
);
// Advance to 11:00 - first campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(11);
// Advance to 12:00 - second campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(12);
// Advance to 13:00 - third campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(13);
// All campaigns should still be in scheduled state (until processor runs)
const campaigns = await prisma.campaign.findMany({
where: {projectId},
orderBy: {scheduledFor: 'asc'},
});
expect(campaigns).toHaveLength(3);
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
timeControl.restore();
});
});
describe('Campaign Status Transitions', () => {
it('should transition from SCHEDULED to SENDING', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
const scheduledTime = timeControl.helpers.relative(30, 'minute');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: scheduledTime,
});
// Advance past scheduled time
timeControl.helpers.advanceMinutes(31);
// Processor would transition to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const sending = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sending?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should eventually transition from SENDING to SENT', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENDING,
});
// Create some contacts and emails
const contact = await factories.createContact({projectId});
await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
});
// Mark campaign as sent
await prisma.campaign.update({
where: {id: campaign.id},
data: {
status: CampaignStatus.SENT,
sentAt: new Date(),
},
});
const sent = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sent?.status).toBe(CampaignStatus.SENT);
expect(sent?.sentAt).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle campaigns scheduled in the past', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign in the past
const pastTime = new Date('2025-01-19T10:00:00Z');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: pastTime,
});
// Processor should immediately pick this up
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
expect(shouldRun).toBe(true);
timeControl.restore();
});
it('should handle campaigns scheduled far in the future', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign 30 days in the future
const futureTime = timeControl.helpers.relative(30, 'day');
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
// Campaign should not be processable yet
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
expect(shouldNotRun).toBe(true);
// Advance time by 29 days - still not ready
timeControl.helpers.advanceDays(29);
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
// Advance to exactly the scheduled time
timeControl.advanceTo(futureTime);
expect(timeControl.now()).toEqual(futureTime);
timeControl.restore();
});
});
});
@@ -0,0 +1,107 @@
import type {Job} from 'bullmq';
import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
/**
* API Request Cleanup Worker
* Deletes old API request logs to prevent unbounded table growth
* Runs daily to clean up logs older than 30 days (configurable)
*/
const RETENTION_DAYS = 30; // Keep logs for 30 days
const BATCH_SIZE = 10000; // Delete in batches for performance
/**
* Process API request cleanup job
*/
async function processCleanup(job: Job<ApiRequestCleanupJobData>): Promise<{deleted: number}> {
signale.info('[API-REQUEST-CLEANUP] Starting cleanup of old API request logs...');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
let totalDeleted = 0;
let hasMore = true;
try {
// Delete in batches to avoid locking the table for too long
while (hasMore) {
const result = await prisma.apiRequest.deleteMany({
where: {
createdAt: {
lt: cutoffDate,
},
},
// Note: Prisma doesn't support LIMIT in deleteMany, so we use a different approach
});
totalDeleted += result.count;
// If we deleted fewer than batch size, we're done
hasMore = result.count >= BATCH_SIZE;
if (hasMore) {
signale.info(`[API-REQUEST-CLEANUP] Deleted ${totalDeleted} records so far, continuing...`);
// Small delay between batches to reduce database load
await new Promise(resolve => setTimeout(resolve, 100));
}
}
signale.success(
`[API-REQUEST-CLEANUP] Cleanup complete. Deleted ${totalDeleted} records older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
);
// Update job progress
await job.updateProgress(100);
return {deleted: totalDeleted};
} catch (error) {
signale.error('[API-REQUEST-CLEANUP] Error during cleanup:', error);
throw error;
}
}
/**
* Create the API request cleanup worker
*/
export function createApiRequestCleanupWorker(): Worker<ApiRequestCleanupJobData> {
const redisConnection: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
...parseRedisUrl(REDIS_URL),
};
const worker = new Worker<ApiRequestCleanupJobData>('api-request-cleanup', processCleanup, {
connection: redisConnection,
concurrency: 1, // Only run one cleanup job at a time
});
worker.on('completed', job => {
signale.success(`[API-REQUEST-CLEANUP] Job ${job.id} completed:`, job.returnvalue);
});
worker.on('failed', (job, err) => {
signale.error(`[API-REQUEST-CLEANUP] Job ${job?.id} failed:`, err);
});
worker.on('error', err => {
signale.error('[API-REQUEST-CLEANUP] Worker error:', err);
});
return worker;
}
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
const urlObj = new URL(url);
return {
host: urlObj.hostname,
port: parseInt(urlObj.port || '6379', 10),
password: urlObj.password || undefined,
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Background Job: Bulk Contact Action Processor
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {ContactService} from '../services/ContactService.js';
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
successCount: number;
failureCount: number;
errors: {contactId: string; email: string; error: string}[];
}
export function createBulkContactWorker() {
const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => {
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
successCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
try {
let batchResult: {updated?: number; deleted?: number};
switch (operation) {
case 'subscribe':
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'unsubscribe':
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'delete':
batchResult = await ContactService.bulkDelete(projectId, batchIds);
result.successCount += batchResult.deleted || 0;
break;
}
// If some contacts in batch weren't processed, track them as failures
const processedCount = batchResult.updated || batchResult.deleted || 0;
const failedCount = batchIds.length - processedCount;
if (failedCount > 0) {
result.failureCount += failedCount;
// Note: We don't have individual contact details for batch failures
}
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
email: '',
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
}
signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
return result;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
throw error;
}
},
{
connection: bulkContactQueue.opts.connection,
concurrency: 3, // Process max 3 bulk operations concurrently
},
);
worker.on('completed', job => {
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
});
return worker;
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Background Job: Campaign Processor
* Processes campaign batches (queues emails for each contact in the batch)
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>(
campaignQueue.name,
async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
},
{
connection: campaignQueue.opts.connection,
concurrency: 5, // Process up to 5 batches concurrently
},
);
worker.on('completed', job => {
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,60 @@
/**
* Domain Verification Worker
* Processes domain verification jobs from the BullMQ queue
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js';
/**
* Process domain verification job
*/
async function processDomainVerification(job: Job<DomainVerificationJobData>): Promise<void> {
signale.info(`[DOMAIN-VERIFICATION-WORKER] Starting domain verification job ${job.id}`);
try {
await checkDomainVerifications();
signale.success(`[DOMAIN-VERIFICATION-WORKER] Completed domain verification job ${job.id}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the domain verification worker
*/
export function createDomainVerificationWorker(): Worker {
const worker = new Worker<DomainVerificationJobData>(
domainVerificationQueue.name,
async (job: Job<DomainVerificationJobData>) => {
await processDomainVerification(job);
},
{
connection: domainVerificationQueue.opts.connection,
concurrency: 1, // Process one domain verification job at a time
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire verification checks)
},
},
);
worker.on('completed', job => {
signale.success(`[DOMAIN-VERIFICATION-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[DOMAIN-VERIFICATION-WORKER] Worker error:', error);
});
return worker;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Background Job: Domain Verification Checker
* Checks domain verification status with AWS SES
*
* This is processed by BullMQ workers (see domain-verification-processor.ts)
* Scheduled to run every 5 minutes via repeatable jobs
*/
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
import {Keys} from '../services/keys.js';
/**
* Check verification status for all domains in the database
*/
export async function checkDomainVerifications() {
signale.info('[DOMAIN-VERIFICATION] Starting domain verification check...');
try {
const count = await prisma.domain.count();
signale.info(`[DOMAIN-VERIFICATION] Found ${count} domains to check`);
// Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({
select: {id: true, domain: true, projectId: true, verified: true},
skip: i,
take: 99,
});
if (domains.length === 0) {
continue;
}
// Get verification status from AWS SES
const sesIdentities = await getIdentities(domains.map(d => d.domain));
// Update each domain based on SES status
for (const sesIdentity of sesIdentities) {
const dbDomain = domains.find(d => d.domain === sesIdentity.domain);
if (!dbDomain) {
continue;
}
const isVerified = sesIdentity.status === 'Success';
// If domain failed verification, retry
if (sesIdentity.status === 'Failed') {
signale.warn(`[DOMAIN-VERIFICATION] Restarting verification for ${sesIdentity.domain}`);
let attempt = 0;
const maxAttempts = 5;
let success = false;
let delay = 5000;
while (attempt < maxAttempts && !success) {
try {
await verifyDomain(sesIdentity.domain);
success = true;
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
} catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string};
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
await new Promise(r => setTimeout(r, delay));
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
throw e;
}
}
}
if (!success) {
signale.error(
`[DOMAIN-VERIFICATION] Failed to verify ${sesIdentity.domain} after ${maxAttempts} attempts due to throttling`,
);
}
}
// Update verification status in database
await prisma.domain.update({
where: {id: dbDomain.id},
data: {verified: isVerified},
});
// If domain was just verified, disable feedback forwarding
if (!dbDomain.verified && isVerified) {
signale.success(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is now verified!`);
try {
await disableFeedbackForwarding(sesIdentity.domain);
signale.info(`[DOMAIN-VERIFICATION] Disabled feedback forwarding for ${sesIdentity.domain}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
}
// Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
// If domain was unverified, invalidate cache
if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
}
}
signale.success('[DOMAIN-VERIFICATION] Domain verification check completed');
} catch (error) {
signale.error('[DOMAIN-VERIFICATION] Error checking domain verifications:', error);
throw error;
}
}
/**
* Main processor function
* Call this from your scheduler/cron
*/
export async function runDomainVerificationJob() {
await checkDomainVerifications();
}
// If running this file directly (for testing or manual execution)
if (import.meta.url === `file://${process.argv[1]}`) {
signale.info('[DOMAIN-VERIFICATION] Running domain verification job manually...');
runDomainVerificationJob()
.then(() => {
signale.success('[DOMAIN-VERIFICATION] Completed successfully');
process.exit(0);
})
.catch(error => {
signale.error('[DOMAIN-VERIFICATION] Fatal error:', error);
process.exit(1);
});
}
+262
View File
@@ -0,0 +1,262 @@
/**
* Background Job: Email Processor
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
import {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)
* Priority: ENV variable > AWS SES quota > Safe default (14)
*/
async function getEmailRateLimit(): Promise<number> {
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
// If env variable is set, use it (override)
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
return EMAIL_RATE_LIMIT_PER_SECOND;
}
// Try to fetch from AWS SES
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
const quota = await getSendingQuota();
if (quota) {
signale.info(
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
);
return quota.maxSendRate;
}
// Fallback to safe default
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
return DEFAULT_RATE_LIMIT;
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
const {emailId} = job.data;
const email = await prisma.email.findUnique({
where: {id: emailId},
include: {
contact: true,
project: true,
},
});
if (!email) {
throw new Error(`Email ${emailId} not found`);
}
if (email.status !== EmailStatus.PENDING) {
return;
}
// Check if project is disabled
if (email.project.disabled) {
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
return;
}
try {
// Update status to sending
await prisma.email.update({
where: {id: emailId},
data: {status: EmailStatus.SENDING},
});
// Format template variables in subject and body
const contactData = (email.contact.data as Record<string, unknown>) || {};
const formattedEmail = EmailService.format({
subject: email.subject,
body: email.body,
data: {
email: email.contact.email,
...contactData,
data: contactData,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`,
},
});
// Compile HTML with unsubscribe footer and badge
const compiledHtml = EmailService.compile({
content: formattedEmail.body,
contact: email.contact,
project: email.project,
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
});
// Use fromName from database if available, otherwise fall back to project name
// The 'from' field in the database is just the email address
const fromName = email.fromName || email.project.name;
const fromEmail = email.from;
// Build recipient with name if available
const recipient: {name?: string; email: string} | string = email.toName
? {name: email.toName, email: email.contact.email}
: email.contact.email;
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
// Send via AWS SES
const result = await sendRawEmail({
from: {
name: fromName,
email: fromEmail,
},
to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
});
// Mark as sent with SES message ID
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: result.messageId,
},
});
// Record usage for billing (pay-per-email)
// Uses email ID as idempotency key to prevent double-charging on retries
// Charge 2 emails if attachments are present
if (email.project.customer) {
const hasAttachments = email.attachments && Array.isArray(email.attachments) && email.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
await MeterService.recordEmailSent(email.project.customer, emailCount, `email_${emailId}`);
}
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, 'email.sent', email.contactId, email.id, {
subject: formattedEmail.subject,
from: email.from,
fromName: email.fromName,
messageId: result.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
// If this email belongs to a campaign, check if all campaign emails have been sent
if (email.campaignId) {
const campaign = await prisma.campaign.findUnique({
where: {id: email.campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {
select: {name: true},
},
},
});
// Only check if campaign is still in SENDING status
if (campaign && campaign.status === CampaignStatus.SENDING) {
// Count how many emails have been sent for this campaign
const sentCount = await prisma.email.count({
where: {
campaignId: email.campaignId,
sentAt: {not: null},
},
});
// If all emails have been sent, mark campaign as SENT
if (sentCount >= campaign.totalRecipients) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
},
});
signale.success(
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
// Send notification about campaign send completed
const {NtfyService} = await import('../services/NtfyService.js');
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
campaign.totalRecipients,
);
}
}
}
} catch (error) {
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: error instanceof Error ? error.message : 'Unknown error',
},
});
throw error; // Re-throw to trigger retry
}
},
{
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
},
},
);
worker.on('completed', job => {
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
});
return worker;
}
+217
View File
@@ -0,0 +1,217 @@
/**
* Background Job: Contact Import Processor
* Processes CSV contact imports with validation and batch processing
*/
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {NtfyService} from '../services/NtfyService.js';
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface ImportResult {
totalRows: number;
successCount: number;
createdCount: number;
updatedCount: number;
failureCount: number;
errors: {row: number; email: string; error: string}[];
}
export function createImportWorker() {
const worker = new Worker<ContactImportJobData>(
importQueue.name,
async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data;
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
// Fetch project information for notifications
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
const projectName = project?.name || projectId;
const result: ImportResult = {
totalRows: 0,
successCount: 0,
createdCount: 0,
updatedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV with column header normalization
const records = parse(csvContent, {
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
skip_empty_lines: true,
trim: true,
relax_column_count: true, // Allow rows with different column counts
}) as Record<string, string>[];
result.totalRows = records.length;
// Validate row count
if (records.length === 0) {
throw new Error('CSV file is empty');
}
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
// Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
// Validate that 'email' column exists (case-insensitive)
const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
throw new Error('CSV must have an "email" column (case-insensitive)');
}
// Process contacts in batches
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, Math.min(i + BATCH_SIZE, records.length));
// Process batch sequentially (to avoid overwhelming the database)
for (const [batchIndex, record] of batch.entries()) {
const rowNumber = i + batchIndex + 2; // +2 for header row and 1-based index
try {
// Validate email
const email = record.email?.trim();
if (!email) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: '',
error: 'Email is required',
});
continue;
}
// Basic email validation
if (!isValidEmail(email)) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email,
error: 'Invalid email format',
});
continue;
}
// Extract subscribed field if present (case-insensitive)
const subscribedValue = record.subscribed;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
// Handle various truthy/falsy values
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
// Extract custom data (all fields except email and subscribed)
const {email: _, subscribed: __, ...customData} = record;
const data = Object.keys(customData).length > 0 ? customData : undefined;
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact;
// Upsert contact with subscribed value from CSV if provided
// For new contacts, ContactService.upsert defaults to true
// For existing contacts, only update if explicitly provided in CSV
await ContactService.upsert(projectId, email, data, subscribed);
result.successCount++;
if (isUpdate) {
result.updatedCount++;
} else {
result.createdCount++;
}
} catch (error) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: record.email || '',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
// Update progress
const progress = Math.round(((i + batch.length) / records.length) * 100);
await job.updateProgress(progress);
}
signale.info(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
);
// Notify that import has completed
await NtfyService.notifyContactImportCompleted(
projectName,
projectId,
filename,
result.successCount,
result.createdCount,
result.updatedCount,
result.failureCount,
);
return result;
} catch (error) {
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
// Notify that import has failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await NtfyService.notifyContactImportFailed(projectName, projectId, filename, errorMessage);
// Return partial results with error
result.errors.push({
row: 0,
email: '',
error: errorMessage,
});
throw error; // Re-throw to mark job as failed
}
},
{
connection: importQueue.opts.connection,
concurrency: 2, // Process max 2 imports concurrently
},
);
worker.on('completed', job => {
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
});
return worker;
}
/**
* Basic email validation
*/
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Background Job: Scheduled Campaign Processor
* Processes scheduled campaigns when their time arrives
*/
import {CampaignStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>(
scheduledQueue.name,
async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data;
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {
project: {
select: {disabled: true, id: true, name: true},
},
},
});
if (!campaign) {
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return;
}
// Check if project is disabled
if (campaign.project.disabled) {
signale.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
);
await prisma.campaign.update({
where: {id: campaignId},
data: {status: CampaignStatus.CANCELLED},
});
return;
}
// Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) {
signale.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
);
return;
}
// Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId);
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
},
{
connection: scheduledQueue.opts.connection,
concurrency: 2, // Process up to 2 scheduled campaigns concurrently
},
);
worker.on('completed', job => {
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,163 @@
/**
* Segment Count Update Worker
* Processes segment count update jobs from the BullMQ queue
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {NtfyService} from '../services/NtfyService.js';
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
import {SegmentService} from '../services/SegmentService.js';
/**
* Process segments for a single project
* - For segments with trackMembership: compute full membership and trigger events
* - For segments without trackMembership: only update counts
*/
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
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, trackMembership: true},
});
const trackedSegments = 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)
if (trackedSegments.length > 0) {
for (const segment of trackedSegments) {
try {
signale.info(
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${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
if (projectName && (result.added > 0 || result.removed > 0)) {
await NtfyService.notifySegmentMembershipComputed(
segment.name,
projectName,
projectId,
result.total,
result.added,
result.removed,
);
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
// Continue with other segments
}
}
}
// Process non-tracked segments with count-only update (lightweight)
if (nonTrackedSegments.length > 0) {
try {
await SegmentService.refreshAllMemberCounts(projectId);
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
}
}
}
/**
* Process segment count update job
*/
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
const {projectId} = job.data;
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
try {
if (projectId) {
// Process specific project
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
await processProjectSegments(projectId);
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
} else {
// Process all active projects
const projects = await prisma.project.findMany({
where: {disabled: false},
select: {id: true, name: true},
});
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10;
for (let i = 0; i < projects.length; i += PROJECT_BATCH_SIZE) {
const batch = projects.slice(i, i + PROJECT_BATCH_SIZE);
await Promise.all(
batch.map(async project => {
try {
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
await processProjectSegments(project.id, project.name);
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// Don't throw - continue with other projects
}
}),
);
// Small delay between project batches to avoid overwhelming the database
if (i + PROJECT_BATCH_SIZE < projects.length) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the segment count worker
*/
export function createSegmentCountWorker(): Worker {
const worker = new Worker<SegmentCountJobData>(
segmentCountQueue.name,
async (job: Job<SegmentCountJobData>) => {
await processSegmentCountUpdate(job);
},
{
connection: segmentCountQueue.opts.connection,
concurrency: 1, // Process one segment count job at a time to avoid database overload
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire updates)
},
},
);
worker.on('completed', job => {
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[SEGMENT-COUNT-WORKER] Worker error:', error);
});
return worker;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Unified Queue Worker
* Starts all queue processors (email, campaign, scheduled, workflow, import, segment-count, domain-verification)
*
* This should be run as a separate process in production:
* node dist/jobs/worker.js
*/
import {Worker} from 'bullmq';
import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createBulkContactWorker} from './bulk-contact-processor.js';
import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
import {createImportWorker} from './import-processor.js';
import {createScheduledCampaignWorker} from './scheduled-processor.js';
import {createSegmentCountWorker} from './segment-count-processor.js';
import {createWorkflowWorker} from './workflow-processor-queue.js';
const workers: {name: string; worker: Worker}[] = [];
async function startWorkers() {
signale.info('[WORKER] Starting queue workers...');
try {
// Start email worker
const emailWorker = await createEmailWorker();
workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started');
// Start campaign worker
const campaignWorker = createCampaignWorker();
workers.push({name: 'campaign', worker: campaignWorker});
signale.success('[WORKER] Campaign worker started');
// Start scheduled campaign worker
const scheduledWorker = createScheduledCampaignWorker();
workers.push({name: 'scheduled', worker: scheduledWorker});
signale.success('[WORKER] Scheduled campaign worker started');
// Start workflow worker
const workflowWorker = createWorkflowWorker();
workers.push({name: 'workflow', worker: workflowWorker});
signale.success('[WORKER] Workflow worker started');
// Start import worker
const importWorker = createImportWorker();
workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started');
// Start bulk contact action worker
const bulkContactWorker = createBulkContactWorker();
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
signale.success('[WORKER] Bulk contact action worker started');
// Start segment count worker
const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker});
signale.success('[WORKER] Segment count worker started');
// Start domain verification worker
const domainVerificationWorker = createDomainVerificationWorker();
workers.push({name: 'domain-verification', worker: domainVerificationWorker});
signale.success('[WORKER] Domain verification worker started');
// Start API request cleanup worker
const apiRequestCleanupWorker = createApiRequestCleanupWorker();
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
signale.success('[WORKER] API request cleanup worker started');
signale.success('[WORKER] All workers started successfully');
} catch (error) {
signale.error('[WORKER] Failed to start workers:', error);
process.exit(1);
}
}
async function stopWorkers() {
signale.info('[WORKER] Stopping workers...');
for (const {name, worker} of workers) {
try {
await worker.close();
signale.info(`[WORKER] ${name} worker stopped`);
} catch (error) {
signale.error(`[WORKER] Error stopping ${name} worker:`, error);
}
}
signale.success('[WORKER] All workers stopped');
process.exit(0);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
signale.info('[WORKER] Received SIGINT, shutting down gracefully...');
void stopWorkers();
});
process.on('SIGTERM', () => {
signale.info('[WORKER] Received SIGTERM, shutting down gracefully...');
void stopWorkers();
});
process.on('uncaughtException', error => {
signale.error('[WORKER] Uncaught exception:', error);
void stopWorkers();
});
process.on('unhandledRejection', (reason, promise) => {
signale.error('[WORKER] Unhandled rejection at:', promise, 'reason:', reason);
void stopWorkers();
});
// Start workers
void startWorkers();
@@ -0,0 +1,49 @@
/**
* Background Job: Workflow Queue Processor
* Processes workflow steps from the queue (for delayed steps)
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() {
const worker = new Worker<WorkflowStepJobData>(
workflowQueue.name,
async (job: Job<WorkflowStepJobData>) => {
const {executionId, stepId, type, stepExecutionId} = job.data;
if (type === 'timeout') {
// Handle timeout for WAIT_FOR_EVENT steps
if (!stepExecutionId) {
throw new Error('stepExecutionId is required for timeout jobs');
}
await WorkflowExecutionService.processTimeout(executionId, stepId, stepExecutionId);
} else {
// Handle regular step execution
await WorkflowExecutionService.processStepExecution(executionId, stepId);
}
},
{
connection: workflowQueue.opts.connection,
concurrency: 10, // Process up to 10 workflow steps concurrently
},
);
worker.on('completed', job => {
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,494 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import type {NextFunction, Request, Response} from 'express';
import {databaseRequestLogger} from '../requestLogger.js';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('Request Logger Middleware', () => {
const prisma = getPrismaClient();
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let projectId: string;
let userId: string;
beforeEach(async () => {
const {user, project} = await factories.createUserWithProject();
projectId = project.id;
userId = user.id;
// Mock request object
req = {
method: 'POST',
path: '/v1/send',
ip: '192.168.1.100',
socket: {remoteAddress: '192.168.1.100'} as Request['socket'],
get: vi.fn((header: string) => {
if (header === 'user-agent') {
return 'Mozilla/5.0 Test Browser';
}
return undefined;
}),
headers: {
'content-length': '1234',
},
};
// Mock response object with a json function that references the res object
let statusCode = 200;
res = {
locals: {
requestId: 'test-request-id-123',
auth: {
type: 'secret_key',
userId,
projectId,
},
},
get statusCode() {
return statusCode;
},
set statusCode(value: number) {
statusCode = value;
},
json: vi.fn(function (this: Response, body: unknown) {
return body;
}),
};
next = vi.fn();
});
afterEach(async () => {
vi.clearAllMocks();
// Clean up API request logs to avoid duplicate key errors
await prisma.apiRequest.deleteMany({});
});
// ========================================
// SUCCESSFUL REQUEST LOGGING
// ========================================
describe('Successful Request Logging', () => {
it('should log successful API request to database', async () => {
databaseRequestLogger(req as Request, res as Response, next);
// Middleware should call next immediately
expect(next).toHaveBeenCalled();
// Simulate response
const responseBody = {success: true, data: {id: '123'}};
await res.json!(responseBody);
// Wait for async logging to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify database record was created
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.method).toBe('POST');
expect(loggedRequest?.path).toBe('/v1/send');
expect(loggedRequest?.statusCode).toBe(200);
expect(loggedRequest?.projectId).toBe(projectId);
expect(loggedRequest?.userId).toBe(userId);
expect(loggedRequest?.authType).toBe('secret_key');
expect(loggedRequest?.ip).toBe('192.168.1.100');
expect(loggedRequest?.userAgent).toBe('Mozilla/5.0 Test Browser');
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(0);
expect(loggedRequest?.requestSize).toBe(1234);
expect(loggedRequest?.responseSize).toBeGreaterThan(0);
});
it('should log request without auth information', async () => {
res.locals = {
requestId: 'public-request-id',
};
databaseRequestLogger(req as Request, res as Response, next);
const responseBody = {success: true};
await res.json!(responseBody);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'public-request-id'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.projectId).toBeNull();
expect(loggedRequest?.userId).toBeNull();
expect(loggedRequest?.authType).toBeNull();
});
it('should calculate request duration', async () => {
const startTime = Date.now();
databaseRequestLogger(req as Request, res as Response, next);
// Simulate some processing time
await new Promise(resolve => setTimeout(resolve, 50));
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
// Allow for timer imprecision (especially in CI environments)
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45);
expect(loggedRequest?.duration).toBeLessThan(Date.now() - startTime + 100);
});
});
// ========================================
// ERROR REQUEST LOGGING
// ========================================
describe('Error Request Logging', () => {
it('should log failed requests with error details', async () => {
res.statusCode = 400;
databaseRequestLogger(req as Request, res as Response, next);
const errorResponse = {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid email format',
},
};
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.statusCode).toBe(400);
expect(loggedRequest?.errorCode).toBe('VALIDATION_ERROR');
expect(loggedRequest?.errorMessage).toBe('Invalid email format');
});
it('should log 500 errors', async () => {
res.statusCode = 500;
databaseRequestLogger(req as Request, res as Response, next);
const errorResponse = {
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Database connection failed',
},
};
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(500);
expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR');
});
it('should log 404 not found errors', async () => {
res.statusCode = 404;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({
success: false,
error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'},
});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(404);
expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND');
});
});
// ========================================
// SKIP LOGGING FOR EXCLUDED PATHS
// ========================================
describe('Skip Logging for Excluded Paths', () => {
it('should skip logging for health check endpoint', async () => {
req.path = '/health';
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({status: 'ok'});
await new Promise(resolve => setTimeout(resolve, 100));
// Should not create database record
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeNull();
});
it('should skip logging for user session endpoints', async () => {
const sessionPaths = ['/users/@me', '/users/@me/projects', '/users/me', '/users/me/projects'];
for (const path of sessionPaths) {
req.path = path;
res.locals!.requestId = `skip-${path}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({user: 'data'});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `skip-${path}`},
});
expect(loggedRequest).toBeNull();
}
});
it('should skip logging for static assets', async () => {
const assetPaths = ['/assets/logo.png', '/assets/styles.css', '/assets/script.js'];
for (const path of assetPaths) {
req.path = path;
res.locals!.requestId = `asset-${path}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({});
await new Promise(resolve => setTimeout(resolve, 50));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `asset-${path}`},
});
expect(loggedRequest).toBeNull();
}
});
it('should skip logging for config endpoints', async () => {
const reqConfig = {...req, path: '/config'};
const resConfig = {
...res,
locals: {...res.locals!, requestId: 'test-config-skip'},
};
databaseRequestLogger(reqConfig as Request, resConfig as Response, next);
await resConfig.json!({features: {}});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-config-skip'},
});
expect(loggedRequest).toBeNull();
});
it('should LOG important API endpoints', async () => {
const importantPaths = ['/v1/send', '/v1/track', '/contacts', '/campaigns', '/templates'];
for (const path of importantPaths) {
req.path = path;
res.locals!.requestId = `log-${path.replace(/\//g, '-')}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `log-${path.replace(/\//g, '-')}`},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.path).toBe(path);
}
});
});
// ========================================
// CONFIGURATION & ERROR HANDLING
// ========================================
describe('Configuration & Error Handling', () => {
it('should respect REQUEST_LOGGING=false environment variable', async () => {
const originalEnv = process.env.REQUEST_LOGGING;
process.env.REQUEST_LOGGING = 'false';
// Re-import to get updated config
// Note: This test may need to be adjusted based on how your module caching works
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// TODO: Add assertion to verify request was NOT logged when disabled
// const loggedRequest = await prisma.apiRequest.findUnique({
// where: {id: 'test-request-id-123'},
// });
// expect(loggedRequest).toBeNull();
// Restore original value
if (originalEnv !== undefined) {
process.env.REQUEST_LOGGING = originalEnv;
} else {
delete process.env.REQUEST_LOGGING;
}
// This test might fail in current implementation since the env is read at module load time
// Consider this a documentation of desired behavior
});
it('should handle missing request ID gracefully', async () => {
res.locals = {}; // No request ID
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// Should create a record with generated UUID
const allRequests = await prisma.apiRequest.findMany({
where: {
path: '/v1/send',
method: 'POST',
},
orderBy: {createdAt: 'desc'},
take: 1,
});
expect(allRequests.length).toBeGreaterThan(0);
expect(allRequests[0].id).toBeDefined();
});
it('should not block response if database logging fails', async () => {
// Simulate database error by using invalid data
const resInvalid = {
...res,
locals: {
requestId: null as unknown, // Invalid request ID - will cause database error
},
};
databaseRequestLogger(req as Request, resInvalid as Response, next);
// Should not throw and should call next
expect(next).toHaveBeenCalled();
// Response should still work even if logging fails
const result = resInvalid.json!({success: true});
expect(result).toEqual({success: true});
// Wait for async logging to fail silently
await new Promise(resolve => setTimeout(resolve, 100));
// The logging should have failed but not affected the response
});
});
// ========================================
// REQUEST/RESPONSE SIZE TRACKING
// ========================================
describe('Request/Response Size Tracking', () => {
it('should track request size from content-length header', async () => {
// Create a new request with different content-length
const reqWithSize = {
...req,
headers: {
'content-length': '5000',
},
};
// Create a new response with unique request ID
const resWithId = {
...res,
locals: {
...res.locals!,
requestId: 'test-size-5000',
},
};
databaseRequestLogger(reqWithSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-size-5000'},
});
expect(loggedRequest?.requestSize).toBe(5000);
});
it('should handle missing content-length header', async () => {
// Create request without content-length
const reqNoSize = {
...req,
headers: {},
};
const resWithId = {
...res,
locals: {
...res.locals!,
requestId: 'test-no-size',
},
};
databaseRequestLogger(reqNoSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-no-size'},
});
expect(loggedRequest?.requestSize).toBeNull();
});
it('should calculate response size from JSON body', async () => {
const jsonMock = vi.fn(function (this: Response, body: unknown) {
return body;
});
const resLarge = {
...res,
locals: {...res.locals!, requestId: 'test-large-response'},
json: jsonMock,
};
databaseRequestLogger(req as Request, resLarge as Response, next);
const largeResponse = {
success: true,
data: {
items: Array(100).fill({id: '123', name: 'Test Item', description: 'A test item'}),
},
};
await resLarge.json!(largeResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-large-response'},
});
const expectedSize = JSON.stringify(largeResponse).length;
expect(loggedRequest?.responseSize).toBe(expectedSize);
expect(loggedRequest?.responseSize).toBeGreaterThan(1000);
});
});
});
+459
View File
@@ -0,0 +1,459 @@
import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken';
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
export interface AuthResponse {
type: 'jwt' | 'apiKey';
userId?: string;
projectId: string;
}
/**
* Middleware to check if this unsubscribe is authenticated on the dashboard
* @param req
* @param res
* @param next
*/
export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
res.locals.auth = {type: 'jwt', userId: parseJwt(req)};
next();
};
export const jwt = {
/**
* Extracts a unsubscribe id from a jwt
* @param token The JWT token
*/
verify(token: string): string | null {
try {
const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
id: string;
};
return verified.id;
} catch {
return null;
}
},
/**
* Signs a JWT token
* @param id The user's ID to sign into a jwt token
*/
sign(id: string): string {
return jsonwebtoken.sign({id}, JWT_SECRET, {
expiresIn: '168h',
});
},
/**
* Find out when a JWT expires
* @param token The user's jwt token
*/
expires(token: string): dayjs.Dayjs {
const {exp} = jsonwebtoken.verify(token, JWT_SECRET) as {
exp?: number;
};
return dayjs(exp);
},
};
/**
* Parse a user's ID from the request JWT token
* @param request The express request object
*/
export function parseJwt(request: Request): string {
const token: string | undefined = request.cookies.next_token;
if (!token) {
throw new NotAuthenticated();
}
const id = jwt.verify(token);
if (!id) {
throw new NotAuthenticated();
}
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)
* Validates that the request has a valid public key and sets the project
* @param req
* @param res
* @param next
*/
export const requirePublicKey = async (req: Request, res: Response, next: NextFunction) => {
try {
// Get API key from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
}
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
// Look up project by public key only
const project = await prisma.project.findFirst({
where: {
public: apiKey,
},
});
if (!project) {
throw new HttpException(
401,
'Invalid public API key. Ensure you are using a public key (pk_*) for this endpoint.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
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 secret API key authentication
* Validates that the request has a valid secret key and sets the project
* @param req
* @param res
* @param next
*/
export const requireSecretKey = async (req: Request, res: Response, next: NextFunction) => {
try {
// Get API key from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
}
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
// Look up project by secret key only
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
if (!project) {
throw new HttpException(
401,
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
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 authentication - supports both JWT and secret API key
* For JWT: requires X-Project-Id header and validates user has access to the project
* For API key: only accepts secret keys (sk_*), derives project from the key
* @param req
* @param res
* @param next
*/
export const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
try {
// Check for API key first (Authorization header with Bearer token)
const authHeader = req.headers.authorization;
// If Authorization header is provided, use secret key authentication
if (authHeader) {
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
// Look up project by secret key only (public keys not allowed)
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
if (!project) {
throw new HttpException(
401,
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
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,
);
}
}
return next();
}
// Otherwise, use JWT authentication
const userId = parseJwt(req);
// Get project ID from header (required for JWT auth)
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 email verification
* Must be used AFTER isAuthenticated or requireProjectAccess
* @param req
* @param res
* @param next
*/
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = res.locals.auth as AuthResponse;
if (auth.type === 'apiKey') {
return next();
}
if (!auth.userId) {
throw new NotAuthenticated();
}
const user = await prisma.user.findUnique({
where: {id: auth.userId},
select: {emailVerified: true, type: true},
});
if (!user) {
throw new NotAuthenticated();
}
// If platform email verification is disabled, skip check
if (!PLUNK_ENABLED) {
return next();
}
// OAuth users are always considered verified
if (user.type !== 'PASSWORD') {
return next();
}
// PASSWORD users must verify email
if (!user.emailVerified) {
throw new HttpException(
403,
'Please verify your email address to access this resource',
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
);
}
next();
} catch (error) {
next(error);
}
};
+22
View File
@@ -0,0 +1,22 @@
import {randomUUID} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
/**
* Middleware to add a unique request ID to each request
* The request ID is used for error tracking and debugging
*/
export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Check if request ID already exists (e.g., from load balancer)
const existingRequestId = req.headers['x-request-id'] as string | undefined;
// Generate new ID if not provided
const requestId = existingRequestId || randomUUID();
// Store in request object for use in handlers
res.locals.requestId = requestId;
// Send back in response headers for client-side tracking
res.setHeader('X-Request-ID', requestId);
next();
};
+192
View File
@@ -0,0 +1,192 @@
import type {NextFunction, Request, Response} from 'express';
import {prisma} from '../database/prisma.js';
import {logger} from '../utils/logger.js';
/**
* Check if request logging is enabled via environment variable
* Set REQUEST_LOGGING=false to disable database logging entirely
*/
const REQUEST_LOGGING_ENABLED = process.env.REQUEST_LOGGING !== 'false';
/**
* Endpoints to exclude from database logging
*
* Guidelines for what to skip:
* - Health checks and monitoring endpoints (called by load balancers every few seconds)
* - User session/auth endpoints (called on every page load in the dashboard)
* - Real-time polling endpoints (called repeatedly for live updates)
* - Static asset requests (should be served by CDN anyway)
* - Internal/admin-only endpoints that don't need audit trails
*
* What TO log (valuable for analytics/debugging):
* - Public API endpoints (/v1/send, /v1/track) - track customer usage
* - Resource CRUD operations (contacts, templates, campaigns) - audit trail
* - Authentication attempts (login, signup) - security monitoring
* - Payment/billing operations - compliance
* - Failed requests (always logged regardless of endpoint)
*/
const SKIP_LOGGING_PATHS = [
// Health/status checks (called by load balancers constantly)
'/health',
'/',
// User session endpoints (called on every dashboard page load)
'/users/@me',
'/users/@me/projects',
'/users/me',
'/users/me/projects',
// Project switching (called frequently when user switches projects)
'/users/@me/projects/:id',
// Real-time polling/stats endpoints
'/queue/stats',
// Feature flags/config (called on app initialization)
'/config',
'/config/features',
'/oauth-config',
// Field introspection (called when building forms/filters)
'/contacts/fields',
'/events/names',
];
/**
* Path patterns to exclude (regex)
* For more complex matching like "/assets/*" or "*.json"
*/
const SKIP_LOGGING_PATTERNS = [
/^\/assets\//i, // Static assets
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i, // Static files
];
/**
* Check if a request should be logged to the database
*/
function shouldLogRequest(req: Request): boolean {
const path = req.path;
// Check exact path matches
if (SKIP_LOGGING_PATHS.includes(path)) {
return false;
}
// Check pattern matches
if (SKIP_LOGGING_PATTERNS.some(pattern => pattern.test(path))) {
return false;
}
// Log everything else
return true;
}
/**
* Middleware to log API requests to the database for historical tracking and analytics
* This runs asynchronously and does not block the response
*
* IMPORTANT: Only logs important endpoints to avoid noise and reduce database load
*
* Configuration:
* - Set REQUEST_LOGGING=false to disable entirely
* - Modify SKIP_LOGGING_PATHS to exclude specific endpoints
* - Modify SKIP_LOGGING_PATTERNS to exclude path patterns
*/
export const databaseRequestLogger = (req: Request, res: Response, next: NextFunction) => {
// Check if logging is enabled
if (!REQUEST_LOGGING_ENABLED) {
return next();
}
// Skip logging for excluded endpoints
if (!shouldLogRequest(req)) {
return next();
}
const startTime = Date.now();
// Capture the original res.json to log after response
const originalJson = res.json.bind(res);
res.json = function (body: unknown) {
// Call original json method first
const result = originalJson(body);
// Log to database asynchronously (don't await, don't block response)
void logRequestToDatabase(req, res, startTime, body);
return result;
};
next();
};
/**
* Log the request to the database
* This runs asynchronously and failures are logged but don't affect the response
*/
async function logRequestToDatabase(
req: Request,
res: Response,
startTime: number,
responseBody: unknown,
): Promise<void> {
try {
const requestId = res.locals.requestId as string | undefined;
const auth = res.locals.auth as {type?: string; userId?: string; projectId?: string} | undefined;
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
// Extract error information if this is an error response
let errorCode: string | undefined;
let errorMessage: string | undefined;
if (
statusCode >= 400 &&
responseBody &&
typeof responseBody === 'object' &&
'error' in responseBody &&
responseBody.error &&
typeof responseBody.error === 'object'
) {
const error = responseBody.error as {code?: string; message?: string};
errorCode = error.code;
errorMessage = error.message;
}
// Calculate request/response sizes (approximate)
const requestSize = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : undefined;
const responseSize = JSON.stringify(responseBody).length;
// Insert into database
await prisma.apiRequest.create({
data: {
id: requestId || crypto.randomUUID(), // Use request ID as primary key
method: req.method,
path: req.path,
statusCode,
duration,
projectId: auth?.projectId || null,
userId: auth?.userId || null,
authType: auth?.type || null,
ip: req.ip || req.socket.remoteAddress || null,
userAgent: req.get('user-agent') || null,
errorCode: errorCode || null,
errorMessage: errorMessage || null,
requestSize: requestSize || null,
responseSize,
},
});
// Log success for debugging (only in development)
if (process.env.NODE_ENV === 'development') {
logger.debug('Request logged to database', {requestId}, res);
}
} catch (error) {
// Log the error but don't throw - we don't want logging failures to affect the API
logger.error('Failed to log request to database', error, {
path: req.path,
method: req.method,
});
}
}
+731
View File
@@ -0,0 +1,731 @@
import type {Prisma} from '@plunk/db';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Activity types that can be tracked
*/
export enum ActivityType {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
/**
* Unified activity item
*/
export interface Activity {
id: string;
type: ActivityType;
timestamp: Date;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
/**
* Paginated activity response
*/
export interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
/**
* Activity stats for dashboard
*/
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
/**
* Activity Service
*
* PERFORMANCE CONSIDERATIONS:
* - Uses cursor-based pagination for efficient large dataset handling
* - Limits date range to prevent expensive queries (default 30 days)
* - Caches statistics in Redis with 5-minute TTL
* - Uses indexed fields (createdAt) for sorting
* - Batch processes and merges results from multiple tables
*/
export class ActivityService {
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
private static readonly DEFAULT_DAYS_BACK = 30;
private static readonly STATS_CACHE_TTL = 300; // 5 minutes
/**
* Get unified activity feed for a project
*
* Performance: O(n log n) where n = limit
* - Fetches up to `limit` items from 3 tables in parallel
* - Merges and sorts by timestamp
* - Returns top `limit` items
*
* PAGINATION APPROACH:
* This implementation fetches `limit` items from each source (Events, Emails, Workflows),
* then merges and returns the top `limit` results by timestamp. This ensures a proper
* chronological timeline but has tradeoffs:
*
* Pros:
* - Proper time-based ordering across all activity types
* - Simple cursor-based pagination
* - Efficient for typical use cases
*
* Cons:
* - May fetch more items from DB than returned to client (up to 3x limit)
* - Cursor pagination across sources can miss items in rare edge cases
*
* For higher scale (10M+ activities), consider:
* - Materialized view or unified activity table
* - Event sourcing pattern with proper indexing
* - Separate pagination per activity type
*/
public static async getActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
cursor?: string,
types?: ActivityType[],
contactId?: string,
startDate?: Date,
endDate?: Date,
): Promise<PaginatedActivities> {
// Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
// Fetch extra items from each source to ensure we have enough after merging
// We fetch limit items from each, then take top limit after sorting
const fetchLimit = effectiveLimit;
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Parse cursor if provided (format: timestamp_id)
let cursorTimestamp: Date | undefined;
let cursorId: string | undefined;
if (cursor) {
const [timestamp, id] = cursor.split('_');
cursorTimestamp = timestamp ? new Date(parseInt(timestamp)) : undefined;
cursorId = id;
}
// Fetch activities from different sources in parallel
// Each source fetches up to fetchLimit items
const [events, emails, workflows] = await Promise.all([
this.fetchEvents(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchEmailActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchWorkflowActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
]);
// Merge all activities
const allActivities = [...events, ...emails, ...workflows];
// Sort by timestamp descending (most recent first)
allActivities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Take only the requested limit + 1 (to check if there are more)
const paginatedActivities = allActivities.slice(0, effectiveLimit + 1);
// Check if there are more results
const hasMore = paginatedActivities.length > effectiveLimit;
const results = hasMore ? paginatedActivities.slice(0, effectiveLimit) : paginatedActivities;
// Generate cursor from the last item
const lastActivity = results[results.length - 1];
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return {
activities: results,
nextCursor,
hasMore,
};
}
/**
* Get activity statistics for a project
*
* Performance: Uses Redis cache with 5-minute TTL
* Falls back to database aggregation if cache miss
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
}
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Compute stats from database (in parallel for performance)
const [totalEvents, emailStats] = await Promise.all([
// Count total events
prisma.event.count({
where: {
projectId,
createdAt: dateFilter,
},
}),
// Aggregate email stats
prisma.email.aggregate({
where: {
projectId,
createdAt: dateFilter,
},
_count: {
id: true,
openedAt: true,
clickedAt: true,
},
}),
]);
// Count workflow executions
const totalWorkflowsStarted = await prisma.workflowExecution.count({
where: {
workflow: {
projectId,
},
startedAt: dateFilter,
},
});
const totalEmailsSent = emailStats._count.id;
const totalEmailsOpened = emailStats._count.openedAt || 0;
const totalEmailsClicked = emailStats._count.clickedAt || 0;
const stats: ActivityStats = {
totalEvents,
totalEmailsSent,
totalEmailsOpened,
totalEmailsClicked,
totalWorkflowsStarted,
openRate: totalEmailsSent > 0 ? (totalEmailsOpened / totalEmailsSent) * 100 : 0,
clickRate: totalEmailsSent > 0 ? (totalEmailsClicked / totalEmailsSent) * 100 : 0,
};
// Cache for 5 minutes
try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) {
signale.warn('[ACTIVITY] Failed to cache stats:', error);
}
return stats;
}
/**
* Invalidate activity stats cache for a project
* Should be called when new activities are created
*/
public static async invalidateStatsCache(projectId: string): Promise<void> {
try {
// Delete all cache keys for this project
const pattern = `activity:stats:${projectId}:*`;
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
}
}
/**
* Get recent activity count (for real-time updates)
* Returns count of activities in the last N minutes
*/
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
const since = new Date(Date.now() - minutes * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {gte: since};
const [eventCount, emailCount, workflowCount] = await Promise.all([
prisma.event.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.email.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.workflowExecution.count({
where: {
workflow: {projectId},
startedAt: dateFilter,
},
}),
]);
return eventCount + emailCount + workflowCount;
}
/**
* Get upcoming scheduled activities for a project
*
* Fetches scheduled campaigns and workflow step executions that are
* scheduled to send emails in the future.
*
* @param projectId - Project ID
* @param limit - Max number of items to return (default 50)
* @param daysAhead - How many days into the future to look (default 30)
*/
public static async getUpcomingActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
daysAhead = this.DEFAULT_DAYS_BACK,
): Promise<Activity[]> {
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
const now = new Date();
const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: now,
lte: futureDate,
};
// Fetch scheduled items in parallel
const [scheduledCampaigns, scheduledWorkflowSteps] = await Promise.all([
this.fetchScheduledCampaigns(projectId, effectiveLimit, dateFilter),
this.fetchScheduledWorkflowSteps(projectId, effectiveLimit, dateFilter),
]);
// Merge all scheduled activities
const allActivities = [...scheduledCampaigns, ...scheduledWorkflowSteps];
// Sort by timestamp ascending (earliest first for upcoming items)
allActivities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
// Return up to the limit
return allActivities.slice(0, effectiveLimit);
}
/**
* Fetch event activities
*/
private static async fetchEvents(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and event.triggered is not included
if (types && !types.includes(ActivityType.EVENT_TRIGGERED)) {
return [];
}
const where: Prisma.EventWhereInput = {
projectId,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
...(contactId ? {contactId} : {}),
};
const events = await prisma.event.findMany({
where,
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
return events.map(event => ({
id: event.id,
type: ActivityType.EVENT_TRIGGERED,
timestamp: event.createdAt,
contactEmail: event.contact?.email,
contactId: event.contactId || undefined,
metadata: {
eventName: event.name,
eventData: event.data,
},
}));
}
/**
* Fetch email activities (sent, delivered, opened, clicked, bounced)
*/
private static async fetchEmailActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
const activities: Activity[] = [];
// Determine which email statuses to fetch based on types filter
const emailTypes = types
? types.filter(t => t.startsWith('email.'))
: Object.values(ActivityType).filter(t => t.startsWith('email.'));
if (emailTypes.length === 0) {
return [];
}
const where: Prisma.EmailWhereInput = {
projectId,
...(contactId ? {contactId} : {}),
};
const emails = await prisma.email.findMany({
where: {
...where,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
campaign: {
select: {
name: true,
},
},
workflowExecution: {
select: {
workflow: {
select: {
name: true,
},
},
},
},
},
});
// Convert each email into multiple activities based on its state
for (const email of emails) {
const baseMetadata = {
subject: email.subject,
body: email.body,
from: email.from,
fromName: email.fromName,
replyTo: email.replyTo,
toName: email.toName,
sourceType: email.sourceType,
campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name,
};
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
activities.push({
id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT,
timestamp: email.sentAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
activities.push({
id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED,
timestamp: email.deliveredAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
activities.push({
id: `${email.id}_opened`,
type: ActivityType.EMAIL_OPENED,
timestamp: email.openedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalOpens: email.opens,
},
});
}
// Email clicked
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
activities.push({
id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED,
timestamp: email.clickedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalClicks: email.clicks,
},
});
}
// Email bounced
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
activities.push({
id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED,
timestamp: email.bouncedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
}
return activities;
}
/**
* Fetch workflow activities
*/
private static async fetchWorkflowActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and workflow types are not included
if (types && !types.some(t => t.startsWith('workflow.'))) {
return [];
}
const activities: Activity[] = [];
const where: Prisma.WorkflowExecutionWhereInput = {
workflow: {
projectId,
},
...(contactId ? {contactId} : {}),
};
const executions = await prisma.workflowExecution.findMany({
where: {
...where,
startedAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
},
orderBy: {startedAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
});
for (const execution of executions) {
// Workflow started
if (!types || types.includes(ActivityType.WORKFLOW_STARTED)) {
activities.push({
id: `${execution.id}_started`,
type: ActivityType.WORKFLOW_STARTED,
timestamp: execution.startedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
},
});
}
// Workflow completed
if (execution.completedAt && (!types || types.includes(ActivityType.WORKFLOW_COMPLETED))) {
activities.push({
id: `${execution.id}_completed`,
type: ActivityType.WORKFLOW_COMPLETED,
timestamp: execution.completedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
exitReason: execution.exitReason,
},
});
}
}
return activities;
}
/**
* Fetch scheduled campaigns
*/
private static async fetchScheduledCampaigns(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const campaigns = await prisma.campaign.findMany({
where: {
projectId,
status: 'SCHEDULED',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
segment: {
select: {
name: true,
},
},
},
});
return campaigns.map(campaign => ({
id: `campaign_${campaign.id}_scheduled`,
type: ActivityType.CAMPAIGN_SCHEDULED,
timestamp: campaign.scheduledFor!,
metadata: {
campaignName: campaign.name,
subject: campaign.subject,
totalRecipients: campaign.totalRecipients,
segmentName: campaign.segment?.name,
audienceType: campaign.audienceType,
},
}));
}
/**
* Fetch scheduled workflow step executions (emails with delays)
*/
private static async fetchScheduledWorkflowSteps(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {
execution: {
workflow: {
projectId,
},
},
status: 'PENDING',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
execution: {
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
},
step: {
select: {
name: true,
type: true,
config: true,
},
},
},
});
return stepExecutions
.filter(stepExec => stepExec.step.type === 'SEND_EMAIL' && stepExec.scheduledFor)
.map(stepExec => ({
id: `workflow_step_${stepExec.id}_scheduled`,
type: ActivityType.WORKFLOW_EMAIL_SCHEDULED,
timestamp: stepExec.scheduledFor!,
contactEmail: stepExec.execution.contact?.email,
contactId: stepExec.execution.contactId,
metadata: {
workflowName: stepExec.execution.workflow.name,
stepName: stepExec.step.name,
subject:
stepExec.step.config &&
typeof stepExec.step.config === 'object' &&
stepExec.step.config !== null &&
'subject' in stepExec.step.config &&
typeof (stepExec.step.config as Record<string, unknown>).subject === 'string'
? (stepExec.step.config as Record<string, unknown>).subject
: undefined,
},
}));
}
}
+415
View File
@@ -0,0 +1,415 @@
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Time series data point for analytics
*/
export interface TimeSeriesDataPoint {
date: string;
emails: number;
opens: number;
clicks: number;
bounces: number;
delivered: number;
}
/**
* Analytics Service
*
* PERFORMANCE CONSIDERATIONS:
* - Aggregates data by day to reduce row count
* - Uses Redis caching with 15-minute TTL
* - Limits date ranges to prevent expensive queries
* - Uses indexed fields (createdAt, projectId) for efficient filtering
* - For 1M+ emails, consider background jobs for pre-aggregation
*/
export class AnalyticsService {
private static readonly DEFAULT_DAYS_BACK = 30;
private static readonly MAX_DAYS_BACK = 90;
private static readonly TIMESERIES_CACHE_TTL = 900; // 15 minutes
/**
* Get time series data for email analytics
*
* Performance: O(n) where n = number of emails in date range
* - Groups emails by day using SQL aggregation
* - Cached in Redis for 15 minutes
* - Limited to 90 days max to prevent performance issues
*
* For higher scale (1M+ emails/day), consider:
* - Pre-aggregated daily stats table updated by background job
* - Materialized view with daily refresh
* - Time-series database (TimescaleDB, InfluxDB)
*/
public static async getTimeSeriesData(
projectId: string,
startDate?: Date,
endDate?: Date,
): Promise<TimeSeriesDataPoint[]> {
// Calculate date range with limits
const now = new Date();
const effectiveEndDate = endDate || now;
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
// Enforce max date range
const maxStartDate = new Date(now.getTime() - this.MAX_DAYS_BACK * 24 * 60 * 60 * 1000);
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Raw SQL query for efficient daily aggregation
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
const result = await prisma.$queryRaw<
{
date: Date;
total_emails: bigint;
total_opens: bigint;
total_clicks: bigint;
total_bounces: bigint;
total_delivered: bigint;
}[]
>`
SELECT
DATE_TRUNC('day', "createdAt") as date,
COUNT(*) as total_emails,
COUNT(CASE WHEN "openedAt" IS NOT NULL THEN 1 END) as total_opens,
COUNT(CASE WHEN "clickedAt" IS NOT NULL THEN 1 END) as total_clicks,
COUNT(CASE WHEN "bouncedAt" IS NOT NULL THEN 1 END) as total_bounces,
COUNT(CASE WHEN "deliveredAt" IS NOT NULL THEN 1 END) as total_delivered
FROM "emails"
WHERE "projectId" = ${projectId}
AND "createdAt" >= ${limitedStartDate}
AND "createdAt" <= ${effectiveEndDate}
GROUP BY DATE_TRUNC('day', "createdAt")
ORDER BY date ASC
`;
// Convert to TimeSeriesDataPoint format
const timeSeries: TimeSeriesDataPoint[] = result.map(row => ({
date: row.date.toISOString(),
emails: Number(row.total_emails),
opens: Number(row.total_opens),
clicks: Number(row.total_clicks),
bounces: Number(row.total_bounces),
delivered: Number(row.total_delivered),
}));
// Fill in missing dates with zero values
const filledTimeSeries = this.fillMissingDates(timeSeries, limitedStartDate, effectiveEndDate);
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(filledTimeSeries));
return filledTimeSeries;
}
/**
* Get campaign performance metrics
* Returns top performing campaigns by open rate
*/
public static async getTopCampaigns(
projectId: string,
limit = 10,
startDate?: Date,
endDate?: Date,
): Promise<
{
id: string;
subject: string;
sentCount: number;
openedCount: number;
clickedCount: number;
openRate: number;
clickRate: number;
}[]
> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const campaigns = await prisma.campaign.findMany({
where: {
projectId,
sentAt: {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
},
status: 'SENT',
},
select: {
id: true,
subject: true,
sentCount: true,
openedCount: true,
clickedCount: true,
},
orderBy: {
openedCount: 'desc',
},
take: limit,
});
return campaigns.map(campaign => ({
id: campaign.id,
subject: campaign.subject || 'No subject',
sentCount: campaign.sentCount || 0,
openedCount: campaign.openedCount || 0,
clickedCount: campaign.clickedCount || 0,
openRate: campaign.sentCount ? ((campaign.openedCount || 0) / campaign.sentCount) * 100 : 0,
clickRate: campaign.sentCount ? ((campaign.clickedCount || 0) / campaign.sentCount) * 100 : 0,
}));
}
/**
* Get campaign statistics overview
* Returns aggregate stats for all campaigns in date range
*
* Performance: O(1) with indexed queries
* - Uses aggregation on indexed fields
* - Cached in Redis for 15 minutes
*/
public static async getCampaignStats(
projectId: string,
startDate?: Date,
endDate?: Date,
): Promise<{
total: number;
active: number;
completed: number;
averageOpenRate: number;
averageClickRate: number;
}> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Get all campaigns in date range
const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([
// Total campaigns created in date range
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
}),
// Active campaigns (not sent yet)
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: {
in: ['DRAFT', 'SCHEDULED'],
},
},
}),
// Sent campaigns with stats
prisma.campaign.findMany({
where: {
projectId,
sentAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: 'SENT',
},
select: {
sentCount: true,
openedCount: true,
clickedCount: true,
},
}),
]);
// Calculate averages
let totalSent = 0;
let totalOpened = 0;
let totalClicked = 0;
sentCampaigns.forEach(campaign => {
totalSent += campaign.sentCount || 0;
totalOpened += campaign.openedCount || 0;
totalClicked += campaign.clickedCount || 0;
});
const completed = sentCampaigns.length;
const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0;
const stats = {
total: totalCampaigns,
active: activeCampaigns,
completed,
averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal
averageClickRate: Math.round(averageClickRate * 10) / 10,
};
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats));
return stats;
}
/**
* Get top events by frequency with trend data
*
* Performance: O(n log n) where n = number of unique events
* - Groups events and counts occurrences
* - Compares with previous period for trend calculation
* - Cached in Redis for 15 minutes
*/
public static async getTopEvents(
projectId: string,
limit = 5,
startDate?: Date,
endDate?: Date,
): Promise<
{
name: string;
count: number;
trend: number;
}[]
> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Calculate previous period for trend comparison
const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime();
const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength);
const previousEndDate = new Date(effectiveStartDate.getTime());
// Get current period events
const currentEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
},
take: limit,
});
// Get previous period events for trend calculation
const previousEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
name: {
in: currentEvents.map(e => e.name),
},
createdAt: {
gte: previousStartDate,
lte: previousEndDate,
},
},
_count: {
id: true,
},
});
// Create map of previous counts
const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id]));
// Calculate trends
const topEvents = currentEvents.map(event => {
const currentCount = event._count.id;
const previousCount = previousCountMap.get(event.name) || 0;
// Calculate percentage change
let trend = 0;
if (previousCount > 0) {
trend = Math.round(((currentCount - previousCount) / previousCount) * 100);
} else if (currentCount > 0) {
trend = 100; // New event, 100% increase
}
return {
name: event.name,
count: currentCount,
trend,
};
});
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents));
return topEvents;
}
/**
* Fill in missing dates in time series with zero values
* Ensures consistent daily data points even when no emails were sent
*/
private static fillMissingDates(data: TimeSeriesDataPoint[], startDate: Date, endDate: Date): TimeSeriesDataPoint[] {
const result: TimeSeriesDataPoint[] = [];
const dataMap = new Map(data.map(point => [new Date(point.date).toDateString(), point]));
// Iterate through each day in range
const currentDate = new Date(startDate);
currentDate.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
while (currentDate <= end) {
const dateKey = currentDate.toDateString();
const existingData = dataMap.get(dateKey);
if (existingData) {
result.push(existingData);
} else {
// Fill with zeros for days with no data
result.push({
date: new Date(currentDate).toISOString(),
emails: 0,
opens: 0,
clicks: 0,
bounces: 0,
delivered: 0,
});
}
// Move to next day
currentDate.setDate(currentDate.getDate() + 1);
}
return result;
}
}
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcrypt';
import {UserService} from './UserService.js';
export class AuthService {
public static async generateHash(password: string): Promise<string> {
return new Promise((resolve, reject) => {
void bcrypt.hash(password, 10, (err, res) => {
if (err) {
return reject(err);
}
resolve(res);
});
});
}
public static async verifyHash(password: string, hash: string) {
return new Promise((resolve, reject) => {
void bcrypt.compare(password, hash, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
});
});
}
public static async verifyCredentials(email: string, password: string) {
const user = await UserService.email(email);
if (!user?.password) {
return false;
}
return await this.verifyHash(password, user.password);
}
}
@@ -0,0 +1,701 @@
import {EmailSourceType} from '@plunk/db';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
/**
* Usage information for a specific email category
*/
export interface CategoryUsage {
limit: number | null; // null = unlimited
usage: number;
percentage: number; // 0-100
isWarning: boolean; // true if >= 80%
isBlocked: boolean; // true if >= 100%
}
/**
* Complete billing limits and usage for a project
*/
export interface BillingLimitsResponse {
workflows: CategoryUsage;
campaigns: CategoryUsage;
transactional: CategoryUsage;
currency: string | null;
}
/**
* Result of limit check
*/
export interface LimitCheckResult {
allowed: boolean;
warning: boolean; // true if >= 80% but < 100%
usage: number;
limit: number | null;
percentage: number;
message?: string;
}
/**
* Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category
*
* FREE TIER LIMITS:
* - Free tier projects (billing enabled, no subscription) have a total limit of 1000 emails/month
* - This limit is shared across all email types (workflows + campaigns + transactional)
* - Paid tier projects (with subscription) can have custom per-category limits or unlimited
*
* PERFORMANCE CONSIDERATIONS:
* - Operates at scale with 1M+ contacts/month (potentially millions of emails)
* - Uses Redis caching (5-min TTL) to avoid expensive DB queries on every email send
* - Composite index on (projectId, sourceType, createdAt) enables fast filtered counts
* - Graceful degradation: cache misses fall back to DB without blocking
* - Non-blocking: errors are logged but don't prevent email sending
*/
export class BillingLimitService {
private static readonly CACHE_TTL = 300; // 5 minutes
private static readonly WARNING_THRESHOLD = 0.8; // 80%
private static readonly FREE_TIER_TOTAL_LIMIT = 1000; // Total emails per month for free tier projects
/**
* Get total usage count across all email categories
* Used for free tier total limit enforcement
*
* @param projectId - Project ID
* @returns Total email count for the calendar month (all types combined)
*/
public static async getTotalUsage(projectId: string): Promise<number> {
const {start, end} = this.getCurrentMonthRange();
try {
const count = await prisma.email.count({
where: {
projectId,
createdAt: {
gte: start,
lt: end,
},
},
});
return count;
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to query total usage for ${projectId}:`, error);
return 0; // Return 0 on error to avoid blocking
}
}
/**
* Get current usage count for a specific email category
* Uses Redis cache with 5-minute TTL to minimize DB queries
*
* @param projectId - Project ID
* @param sourceType - Email category (TRANSACTIONAL, CAMPAIGN, WORKFLOW)
* @returns Current usage count for the calendar month
*/
public static async getUsage(projectId: string, sourceType: EmailSourceType): Promise<number> {
const cacheKey = this.getCacheKey(projectId, sourceType);
try {
// Try to get from cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return parseInt(cached, 10);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Cache read failed for ${cacheKey}:`, error);
// Continue to DB query on cache failure
}
// Cache miss - query database
const {start, end} = this.getCurrentMonthRange();
try {
const count = await prisma.email.count({
where: {
projectId,
sourceType,
createdAt: {
gte: start,
lt: end,
},
},
});
// Cache the result
try {
await redis.setex(cacheKey, this.CACHE_TTL, count.toString());
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to cache usage for ${cacheKey}:`, error);
}
return count;
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to query usage for ${projectId}/${sourceType}:`, error);
return 0; // Return 0 on error to avoid blocking
}
}
/**
* Increment usage counter in cache (called after successful email send)
* This keeps the cache accurate without requiring frequent DB queries
*
* @param projectId - Project ID
* @param sourceType - Email category
*/
public static async incrementUsage(projectId: string, sourceType: EmailSourceType): Promise<void> {
const cacheKey = this.getCacheKey(projectId, sourceType);
try {
const exists = await redis.exists(cacheKey);
if (exists) {
// Only increment if key exists (was previously cached)
await redis.incr(cacheKey);
}
// If not cached, next getUsage call will fetch from DB
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to increment usage cache for ${cacheKey}:`, error);
// Non-blocking: continue even if cache increment fails
}
}
/**
* Check if sending an email would exceed the billing limit
* Returns detailed result including warning status
*
* For free tier projects (no subscription): checks total usage across all categories against 1000/month limit
* For paid tier projects (with subscription): checks per-category limits if set
*
* @param projectId - Project ID
* @param sourceType - Email category
* @returns LimitCheckResult with allowed/warning status
*/
public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> {
try {
// Get project billing info
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {
name: true,
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
},
});
if (!project) {
signale.warn(`[BILLING_LIMIT] Project ${projectId} not found`);
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
// Determine which limit to use based on source type
let limit: number | null;
switch (sourceType) {
case EmailSourceType.WORKFLOW:
limit = project.billingLimitWorkflows;
break;
case EmailSourceType.CAMPAIGN:
limit = project.billingLimitCampaigns;
break;
case EmailSourceType.TRANSACTIONAL:
limit = project.billingLimitTransactional;
break;
default:
limit = null;
}
// Check if any custom per-category limits are set
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== null ||
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
// Only enforce free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = await this.getTotalUsage(projectId);
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / freeLimit) * 100;
// Check if blocked (at or over limit)
if (totalUsage >= freeLimit) {
await NtfyService.notifyBillingLimitExceeded(
project.name,
projectId,
totalUsage,
freeLimit,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
return {
allowed: false,
warning: false,
usage: totalUsage,
limit: freeLimit,
percentage,
message: `Free tier limit reached. You've sent ${totalUsage}/${freeLimit} emails this month. Upgrade to continue sending.`,
};
}
// Check if warning (80% or more)
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
if (isWarning) {
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
totalUsage,
freeLimit,
percentage,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification (only once per month)
await this.sendWarningEmail(
projectId,
project.name,
totalUsage,
freeLimit,
percentage,
'Free Tier (All Types)',
);
}
return {
allowed: true,
warning: isWarning,
usage: totalUsage,
limit: freeLimit,
percentage,
message: isWarning
? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${freeLimit} emails)`
: undefined,
};
}
// If no limit set for paid tier, allow unlimited
if (limit === null) {
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
// Get current usage
const usage = await this.getUsage(projectId, sourceType);
const percentage = limit > 0 ? (usage / limit) * 100 : 0;
// Check if blocked (at or over limit)
if (usage >= limit) {
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
}
return {
allowed: false,
warning: false,
usage,
limit,
percentage,
message: `Billing limit reached for ${sourceType.toLowerCase()} emails. Current usage: ${usage}/${limit} (${Math.round(percentage)}%)`,
};
}
// Check if warning (80% or more)
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
// Send notification for warning threshold
if (isWarning) {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
usage,
limit,
percentage,
sourceType,
);
// Send email notification (only once per month)
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
}
}
return {
allowed: true,
warning: isWarning,
usage,
limit,
percentage,
message: isWarning
? `Warning: ${sourceType.toLowerCase()} emails at ${Math.round(percentage)}% of limit (${usage}/${limit})`
: undefined,
};
} catch (error) {
signale.error(`[BILLING_LIMIT] Error checking limit for ${projectId}/${sourceType}:`, error);
// On error, allow the email to prevent service disruption
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
}
/**
* Get complete billing limits and usage for all categories
* Used for displaying limits in UI
*
* For free tier projects: shows total usage across all categories with 1000/month limit
* For paid tier projects: shows per-category usage with custom limits
*
* @param projectId - Project ID
* @returns Complete billing limits and usage information
*/
public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> {
try {
// Get project info
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {
customer: true,
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
},
});
if (!project) {
throw new Error('Project not found');
}
// Get currency from Stripe customer if available
let currency: string | null = null;
if (stripe && project.customer) {
try {
const customer = await stripe.customers.retrieve(project.customer);
if (!customer.deleted) {
currency = customer.currency || 'usd';
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to fetch currency for customer ${project.customer}:`, error);
// Continue without currency - will be null in response
}
}
// Get usage for all categories in parallel
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
this.getUsage(projectId, EmailSourceType.WORKFLOW),
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
]);
// Helper to calculate category usage
const calculateCategoryUsage = (usage: number, limit: number | null): CategoryUsage => {
const percentage = limit !== null && limit > 0 ? (usage / limit) * 100 : 0;
return {
limit,
usage,
percentage,
isWarning: limit !== null && percentage >= this.WARNING_THRESHOLD * 100,
isBlocked: limit !== null && usage >= limit,
};
};
// Check if any custom per-category limits are set
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== null ||
project.billingLimitTransactional !== null;
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
// Only show free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
const limit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / limit) * 100;
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
const isBlocked = totalUsage >= limit;
// For free tier, show the same limit and total usage for all three categories
// This makes it clear in the UI that it's a shared limit
const sharedUsageInfo: CategoryUsage = {
limit,
usage: totalUsage,
percentage,
isWarning,
isBlocked,
};
return {
workflows: sharedUsageInfo,
campaigns: sharedUsageInfo,
transactional: sharedUsageInfo,
currency,
};
}
// Projects with subscription or custom limits: show per-category limits
return {
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
currency,
};
} catch (error) {
signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, error);
throw error;
}
}
/**
* Invalidate usage cache for a project
* Primarily used in tests to reset cache state between scenarios.
* In production, the cache naturally expires after 5 minutes.
*
* NOTE: Updating billing limits does NOT require clearing usage cache
* (use clearNotificationCacheForChangedLimits instead)
*
* @param projectId - Project ID
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const keys = [
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
];
await Promise.all(keys.map(key => redis.del(key)));
signale.debug(`[BILLING_LIMIT] Invalidated cache for project ${projectId}`);
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to invalidate cache for ${projectId}:`, error);
}
}
/**
* Clear notification cache keys for billing limits that have changed
* This allows new warning/limit emails to be sent when updated limits are reached
*
* @param projectId - Project ID
* @param oldLimits - Previous billing limits
* @param newLimits - New billing limits
*/
public static async clearNotificationCacheForChangedLimits(
projectId: string,
oldLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
newLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
},
): Promise<void> {
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const keysToDelete: string[] = [];
// Check workflows limit
if (oldLimits.workflows !== newLimits.workflows) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
);
}
// Check campaigns limit
if (oldLimits.campaigns !== newLimits.campaigns) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
);
}
// Check transactional limit
if (oldLimits.transactional !== newLimits.transactional) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
);
}
if (keysToDelete.length > 0) {
await Promise.all(keysToDelete.map(key => redis.del(key)));
signale.debug(
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
}
}
/**
* Get Redis cache key for usage count
*/
private static getCacheKey(projectId: string, sourceType: EmailSourceType): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return Keys.Billing.usage(projectId, sourceType, year, month);
}
/**
* Get start and end dates for current calendar month
*/
private static getCurrentMonthRange(): {start: Date; end: Date} {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return {start, end};
}
/**
* Send billing limit warning email to project members
*/
private static async sendWarningEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitWarningEmail, {
projectName,
projectId,
usage,
limit,
percentage,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
// Mark that we've sent the warning email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
}
}
/**
* Send billing limit exceeded email to project members
*/
private static async sendLimitExceededEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitExceededEmail, {
projectName,
projectId,
usage,
limit,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
// Mark that we've sent the limit email (expires at end of month)
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
await redis.setex(cacheKey, ttl, '1');
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
}
}
}
+794
View File
@@ -0,0 +1,794 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
export interface CreateCampaignData {
name: string;
description?: string;
subject: string;
body: string;
from: string;
fromName?: string | 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 {
/**
* Create a new campaign
*/
public static async create(projectId: string, data: CreateCampaignData): Promise<Campaign> {
// Validate segment if provided
if (data.audienceType === CampaignAudienceType.SEGMENT) {
if (!data.segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
const segment = await prisma.segment.findFirst({
where: {
id: data.segmentId,
projectId,
},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
}
// Validate condition if provided
if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) {
// This will throw if condition is invalid
SegmentService.validateCondition(data.audienceCondition);
}
// Create campaign with initial recipient count of 0
const campaign = await prisma.campaign.create({
data: {
projectId,
name: data.name,
description: data.description,
subject: data.subject,
body: data.body,
from: data.from,
fromName: data.fromName,
replyTo: data.replyTo,
audienceType: data.audienceType,
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
},
include: {
project: {
select: {name: true},
},
},
});
// Calculate and update recipient count for the draft
const recipientCount = await this.getRecipientCount(projectId, campaign);
const updatedCampaign = await prisma.campaign.update({
where: {id: campaign.id},
data: {totalRecipients: recipientCount},
});
// Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return updatedCampaign;
}
/**
* Update a campaign
*/
public static async update(projectId: string, campaignId: string, data: UpdateCampaignData): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Can only update draft or scheduled campaigns
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Cannot update campaign that is sending or has been sent');
}
// Build base update data using shared utility
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
// Handle campaign-specific fields
if (data.audienceType !== undefined) {
updateData.audienceType = data.audienceType;
}
if (data.audienceCondition !== undefined) {
if (data.audienceCondition) {
SegmentService.validateCondition(data.audienceCondition);
}
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
}
if (data.segmentId !== undefined) {
// Prevent changing segment on scheduled campaigns
if (campaign.status === CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Cannot change segment for scheduled campaigns');
}
if (data.segmentId) {
const segment = await prisma.segment.findFirst({
where: {id: data.segmentId, projectId},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
updateData.segment = {connect: {id: data.segmentId}};
} else {
updateData.segment = {disconnect: true};
}
// Remove segmentId from updateData to avoid conflict with relation field
delete (updateData as Record<string, unknown>).segmentId;
}
// Update the campaign first
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: updateData,
});
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
const audienceChanged =
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
return prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: recipientCount},
});
}
return updatedCampaign;
}
/**
* Get a campaign
*/
public static async get(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await prisma.campaign.findFirst({
where: {
id: campaignId,
projectId,
},
include: {
segment: true,
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
return campaign;
}
/**
* List campaigns for a project
*/
public static async list(
projectId: string,
options: {
status?: CampaignStatus;
page?: number;
pageSize?: number;
} = {},
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
const {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = {
projectId,
...(status ? {status} : {}),
};
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
include: {
segment: true,
},
orderBy: {createdAt: 'desc'},
skip,
take: pageSize,
}),
prisma.campaign.count({where}),
]);
return {
campaigns,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Delete a campaign
*/
public static async delete(projectId: string, campaignId: string): Promise<void> {
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId, projectId},
include: {
project: {
select: {name: true},
},
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
// Can only delete draft campaigns
if (campaign.status !== CampaignStatus.DRAFT) {
throw new HttpException(400, 'Can only delete draft campaigns');
}
await prisma.campaign.delete({
where: {id: campaignId},
});
// Send notification about campaign deletion
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
}
/**
* Duplicate a campaign
*/
public static async duplicate(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Create a new campaign with the same data but reset status and stats
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId,
name: `${campaign.name} (Copy)`,
description: campaign.description,
subject: campaign.subject,
body: campaign.body,
from: campaign.from,
fromName: campaign.fromName,
replyTo: campaign.replyTo,
audienceType: campaign.audienceType,
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
sentCount: 0,
deliveredCount: 0,
openedCount: 0,
clickedCount: 0,
bouncedCount: 0,
},
});
// Calculate and update recipient count
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
return prisma.campaign.update({
where: {id: duplicatedCampaign.id},
data: {totalRecipients: recipientCount},
});
}
/**
* Send campaign immediately or schedule for later
*/
public static async send(projectId: string, campaignId: string, scheduledFor?: Date): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Validate status
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Campaign has already been sent or is currently sending');
}
// Get recipient count to validate there are contacts to send to
const recipientCount = await this.getRecipientCount(projectId, campaign);
if (recipientCount === 0) {
throw new HttpException(400, 'Campaign has no recipients');
}
// Check billing limits before scheduling/sending the campaign
// This ensures users cannot schedule campaigns that would exceed their quota
if (STRIPE_ENABLED) {
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
// If there's a limit set, verify the campaign won't exceed it
if (limitCheck.limit !== null) {
const projectedUsage = limitCheck.usage + recipientCount;
if (projectedUsage > limitCheck.limit) {
throw new HttpException(
403,
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
);
}
}
}
if (scheduledFor) {
// Schedule for later
if (scheduledFor.getTime() <= Date.now()) {
throw new HttpException(400, 'Scheduled time must be in the future');
}
// Update campaign status
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SCHEDULED,
scheduledFor,
totalRecipients: recipientCount,
},
include: {
project: {
select: {name: true},
},
},
});
// Queue for scheduled sending
await QueueService.scheduleCampaign(campaignId, scheduledFor);
// Send notification about campaign scheduled
await NtfyService.notifyCampaignScheduled(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
scheduledFor,
recipientCount,
);
return updatedCampaign;
} else {
// Send immediately - start the batch processing
await this.startSending(projectId, campaignId, recipientCount);
return this.get(projectId, campaignId);
}
}
/**
* Start sending campaign (called immediately or when scheduled time arrives)
* Now uses cursor-based pagination for better performance with large recipient lists
*/
public static async startSending(projectId: string, campaignId: string, recipientCount?: number): Promise<void> {
const campaign = await this.get(projectId, campaignId);
// Validate status
if (
campaign.status !== CampaignStatus.DRAFT &&
campaign.status !== CampaignStatus.SCHEDULED &&
campaign.status !== CampaignStatus.SENDING
) {
throw new HttpException(400, 'Campaign cannot be sent in its current status');
}
// Get recipient count if not provided
if (recipientCount === undefined) {
recipientCount = await this.getRecipientCount(projectId, campaign);
}
// Update campaign to SENDING status
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENDING,
totalRecipients: recipientCount,
sentAt: new Date(),
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign sending started
await NtfyService.notifyCampaignSendingStarted(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
recipientCount,
);
// Queue first batch to start the cursor-based chain
await QueueService.queueCampaignBatch({
campaignId,
batchNumber: 1,
offset: 0,
limit: BATCH_SIZE,
});
}
/**
* Process a single batch of campaign emails
* Now uses cursor-based pagination for better performance
*/
public static async processBatch(
campaignId: string,
batchNumber: number,
offset: number,
limit: number,
cursor?: string,
): Promise<void> {
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {
project: true,
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
if (campaign.status !== CampaignStatus.SENDING) {
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
return;
}
// Get batch of recipients using cursor-based pagination
const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor);
// Queue emails for each contact
for (const contact of contacts) {
try {
// Render template with contact data
const contactData =
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
const variables = {
email: contact.email,
...contactData,
data: contactData,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
const renderedSubject = EmailService.format({
subject: campaign.subject,
body: '',
data: variables,
}).subject;
const renderedBody = EmailService.format({
subject: '',
body: campaign.body,
data: variables,
}).body;
await EmailService.sendCampaignEmail({
projectId: campaign.projectId,
contactId: contact.id,
campaignId: campaign.id,
templateId: undefined,
subject: renderedSubject,
body: renderedBody,
from: campaign.from,
fromName: campaign.fromName || undefined,
replyTo: campaign.replyTo || undefined,
});
} catch (error) {
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
// Continue with other contacts even if one fails
}
}
// Queue next batch if there are more contacts
if (hasMore && nextCursor) {
await QueueService.queueCampaignBatch({
campaignId,
batchNumber: batchNumber + 1,
offset: 0, // Not used with cursor pagination
limit,
cursor: nextCursor,
});
}
}
/**
* Cancel a campaign
*/
public static async cancel(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Can only cancel scheduled or sending campaigns
if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) {
throw new HttpException(400, 'Can only cancel scheduled or sending campaigns');
}
// If scheduled, remove from queue
if (campaign.status === CampaignStatus.SCHEDULED) {
await QueueService.cancelScheduledCampaign(campaignId);
}
// Update status
const cancelledCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.CANCELLED,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign cancellation
await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId);
return cancelledCampaign;
}
/**
* Get campaign statistics
*/
public static async getStats(projectId: string, campaignId: string) {
const campaign = await this.get(projectId, campaignId);
// Get email stats from Email table
const [sentEmails, deliveredEmails, openedEmails, clickedEmails, bouncedEmails] = await Promise.all([
prisma.email.count({
where: {campaignId, sentAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, deliveredAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, openedAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, clickedAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, bouncedAt: {not: null}},
}),
]);
// Update campaign stats
await prisma.campaign.update({
where: {id: campaignId},
data: {
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
bouncedCount: bouncedEmails,
},
});
return {
totalRecipients: campaign.totalRecipients,
sentCount: sentEmails,
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
bouncedCount: bouncedEmails,
openRate: sentEmails > 0 ? (openedEmails / sentEmails) * 100 : 0,
clickRate: sentEmails > 0 ? (clickedEmails / sentEmails) * 100 : 0,
bounceRate: sentEmails > 0 ? (bouncedEmails / sentEmails) * 100 : 0,
deliveryRate: sentEmails > 0 ? (deliveredEmails / sentEmails) * 100 : 0,
};
}
/**
* Send a test email for a campaign
*/
public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise<void> {
const campaign = await this.get(projectId, campaignId);
// Validate that the test email belongs to a project member
const membership = await prisma.membership.findFirst({
where: {
projectId,
user: {
email: testEmail,
},
},
include: {
user: true,
},
});
if (!membership) {
throw new HttpException(403, 'Test emails can only be sent to project members');
}
// Verify domain is registered and verified before sending
await DomainService.verifyEmailDomain(campaign.from, projectId);
// Get project to validate from address
const project = await prisma.project.findUnique({
where: {id: projectId},
});
if (!project) {
throw new HttpException(404, 'Project not found');
}
// Prepare the email content (no variable replacement for test emails)
await sendRawEmail({
from: {
name: campaign.fromName || project.name || 'Plunk',
email: campaign.from,
},
to: [testEmail],
content: {
subject: `[TEST] ${campaign.subject}`,
html: campaign.body,
},
reply: campaign.replyTo || undefined,
headers: {
'X-Plunk-Test': 'true',
},
tracking: false, // Disable tracking for test emails
});
}
/**
* Get recipient count for a campaign
*/
private static async getRecipientCount(projectId: string, campaign: Campaign): Promise<number> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
return prisma.contact.count({where});
}
/**
* Get recipients for a campaign (legacy offset-based, kept for compatibility)
*/
private static async getRecipients(
projectId: string,
campaign: Campaign,
offset: number,
limit: number,
): Promise<Contact[]> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
return prisma.contact.findMany({
where,
skip: offset,
take: limit,
orderBy: {createdAt: 'asc'}, // Consistent ordering for batching
});
}
/**
* Get recipients for a campaign using cursor-based pagination
*/
private static async getRecipientsCursor(
projectId: string,
campaign: Campaign,
limit: number,
cursor?: string,
): Promise<{contacts: Contact[]; nextCursor?: string; hasMore: boolean}> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
// Fetch one extra to determine if there are more results
const contacts = await prisma.contact.findMany({
where,
take: limit + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? {id: cursor} : undefined,
orderBy: {id: 'asc'}, // Use ID for consistent cursor ordering
});
const hasMore = contacts.length > limit;
const results = hasMore ? contacts.slice(0, -1) : contacts;
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
return {
contacts: results,
nextCursor,
hasMore,
};
}
/**
* Build WHERE clause for campaign recipients (async for segment lookups)
*/
private static async buildRecipientWhereAsync(
projectId: string,
campaign: Campaign,
): Promise<Prisma.ContactWhereInput> {
const baseWhere: Prisma.ContactWhereInput = {
projectId,
subscribed: true, // Only send to subscribed contacts
};
switch (campaign.audienceType) {
case CampaignAudienceType.ALL:
return baseWhere;
case CampaignAudienceType.SEGMENT:
if (!campaign.segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
// Get segment and use its filters
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: {
const condition = campaign.audienceCondition as unknown as FilterCondition;
if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
...baseWhere,
...segmentWhere,
};
}
default:
throw new HttpException(400, 'Invalid audience type');
}
}
/**
* Build WHERE clause for segment-based campaigns
*/
private static async buildSegmentWhereAsync(
projectId: string,
segmentId: string,
baseWhere: Prisma.ContactWhereInput,
): Promise<Prisma.ContactWhereInput> {
// Fetch the segment to get its condition
const segment = await prisma.segment.findUnique({
where: {id: segmentId},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
const condition = segment.condition as unknown as FilterCondition;
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
...baseWhere,
...segmentWhere,
};
}
}
+869
View File
@@ -0,0 +1,869 @@
import {type Contact, Prisma} from '@plunk/db';
import {isValidLanguageCode} from '@plunk/shared';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
cursor?: string;
hasMore: boolean;
}
export class ContactService {
/**
* Get all contacts for a project with cursor-based pagination
* Uses cursor pagination for better performance with large datasets
*/
public static async list(
projectId: string,
limit = 20,
cursor?: string,
search?: string,
): Promise<PaginatedContacts> {
const where: Prisma.ContactWhereInput = {
projectId,
...(search
? {
email: {
contains: search,
mode: 'insensitive' as const,
},
}
: {}),
};
// Fetch one extra to determine if there are more results
// Use composite ordering (createdAt + id) to ensure stable pagination
// This prevents skipping records when multiple contacts have the same createdAt
const contacts = await prisma.contact.findMany({
where,
take: limit + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? {id: cursor} : undefined,
orderBy: [
{createdAt: 'desc'},
{id: 'desc'}, // Secondary sort by id for stable cursor pagination
],
});
const hasMore = contacts.length > limit;
const results = hasMore ? contacts.slice(0, -1) : contacts;
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
// Get total count only on first page for better performance
const total = !cursor ? await prisma.contact.count({where}) : 0;
return {
contacts: results,
total,
cursor: nextCursor,
hasMore,
};
}
/**
* Get a single contact by ID
*/
public static async get(projectId: string, contactId: string): Promise<Contact> {
const contact = await prisma.contact.findFirst({
where: {
id: contactId,
projectId,
},
});
if (!contact) {
throw new HttpException(404, 'Contact not found');
}
return contact;
}
/**
* Find a contact by email (returns null if not found)
*/
public static async findByEmail(projectId: string, email: string): Promise<Contact | null> {
return prisma.contact.findFirst({
where: {
projectId,
email,
},
});
}
/**
* Create a new contact
* Uses unique constraint violation to check for duplicates (more efficient)
*/
public static async create(
projectId: string,
data: {email: string; data?: Prisma.JsonValue; subscribed?: boolean},
): Promise<Contact> {
try {
return await prisma.contact.create({
data: {
projectId,
email: data.email,
data: data.data ?? Prisma.JsonNull,
subscribed: data.subscribed ?? true,
},
});
} catch (error) {
// Check if this is a unique constraint violation (P2002)
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
throw new HttpException(409, 'Contact with this email already exists in this project');
}
throw error;
}
}
/**
* Update a contact
* Uses unique constraint violation to check for duplicates (more efficient)
*/
public static async update(
projectId: string,
contactId: string,
data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean},
): Promise<Contact> {
// First verify contact exists and belongs to project
const existing = await this.get(projectId, contactId);
const updateData: Prisma.ContactUpdateInput = {};
if (data.email !== undefined) {
updateData.email = data.email;
}
if (data.data !== undefined) {
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
}
if (data.subscribed !== undefined) {
updateData.subscribed = data.subscribed;
}
// Track subscription status change
const isSubscriptionChanging = data.subscribed !== undefined && existing.subscribed !== data.subscribed;
const wasSubscribed = existing.subscribed;
try {
const updated = await prisma.contact.update({
where: {id: contactId},
data: updateData,
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (data.subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', contactId);
} else if (!data.subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', contactId);
}
}
return updated;
} catch (error) {
// Check if this is a unique constraint violation (P2002)
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
throw new HttpException(409, 'Contact with this email already exists in this project');
}
throw error;
}
}
/**
* Delete a contact
*/
public static async delete(projectId: string, contactId: string): Promise<void> {
// First verify contact exists and belongs to project
await this.get(projectId, contactId);
await prisma.contact.delete({
where: {id: contactId},
});
}
/**
* Get contact count for a project
*/
public static async count(projectId: string): Promise<number> {
return prisma.contact.count({
where: {projectId},
});
}
/**
* Upsert a contact (create or update) with metadata merging
* Supports persistent and non-persistent data fields
* Reserved fields: plunk_id, plunk_email
*/
public static async upsert(
projectId: string,
email: string,
data?: Record<string, unknown>,
subscribed?: boolean,
): Promise<Contact> {
// Find existing contact
const existing = await prisma.contact.findFirst({
where: {
projectId,
email,
},
});
// Process data to merge with existing data
let mergedData: Record<string, unknown> = {};
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
// Start with existing data
mergedData = {...existing.data};
}
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
// Validate locale field (special user-settable field)
if (key === 'locale') {
if (typeof value === 'string') {
if (!isValidLanguageCode(value)) {
throw new HttpException(
400,
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
);
}
} else if (value !== null && value !== undefined) {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
// Non-persistent fields are not stored in contact data
// They would be used only for the current operation (like email template rendering)
continue;
}
// Store the value
mergedData[key] = value;
}
}
if (existing) {
// Track subscription status change
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
}
}
return updated;
} else {
return prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
subscribed: subscribed ?? true,
},
});
}
}
/**
* Get the full merged data for a contact including non-persistent fields
* This is useful for template rendering
*/
public static getMergedData(contact: Contact, temporaryData?: Record<string, unknown>): Record<string, unknown> {
const mergedData: Record<string, unknown> = {
plunk_id: contact.id,
plunk_email: contact.email,
};
// Add contact's persistent data
if (contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)) {
Object.assign(mergedData, contact.data);
}
// Explicitly expose locale as a predefined field (available in templates)
// This ensures locale is always accessible even if not in contact.data
if (mergedData.locale === undefined) {
mergedData.locale = null;
}
// Add temporary (non-persistent) data
if (temporaryData) {
for (const [key, value] of Object.entries(temporaryData)) {
// Skip reserved system-generated fields
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
mergedData[key] = value.value;
} else {
mergedData[key] = value;
}
}
}
return mergedData;
}
/**
* PUBLIC: Get a contact by ID (no project authentication required)
* This is used for public-facing pages like unsubscribe
*/
public static async getById(contactId: string): Promise<Contact> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
});
if (!contact) {
throw new HttpException(404, 'Contact not found');
}
return contact;
}
/**
* Get project by contact ID
* Used to fetch project settings for public endpoints
*/
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
select: {
project: {
select: {
language: true,
},
},
},
});
return contact?.project || null;
}
/**
* PUBLIC: Subscribe a contact
*/
public static async subscribe(contactId: string): Promise<Contact> {
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: true},
});
// Track subscription event
await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId);
return contact;
}
/**
* PUBLIC: Unsubscribe a contact
*/
public static async unsubscribe(contactId: string): Promise<Contact> {
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: false},
});
// Track unsubscription event
await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId);
return contact;
}
/**
* Get all available contact fields for a project
* Returns both standard fields and custom fields from the data JSON column
* Now includes type information inferred from actual data
*
* @param projectId - The project ID to filter contacts
* @returns Array of field objects with name and type
*/
public static async getAvailableFields(
projectId: string,
): Promise<Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'; coverage: number}>> {
// Get total contact count for coverage calculation
const totalContacts = await prisma.contact.count({
where: {projectId},
});
// Standard fields with known types (always 100% coverage)
const standardFields = [
{field: 'email', type: 'string' as const, coverage: 100},
{field: 'subscribed', type: 'boolean' as const, coverage: 100},
{field: 'createdAt', type: 'date' as const, coverage: 100},
{field: 'updatedAt', type: 'date' as const, coverage: 100},
];
// Get custom fields from the data JSON column with type inference and coverage
// Use raw SQL to extract all keys, sample values, and contact counts from the JSON data column
const result = await prisma.$queryRaw<
Array<{key: string; sample_value: string; json_type: string; contact_count: bigint}>
>`
WITH field_keys AS (
SELECT DISTINCT jsonb_object_keys(data) as key
FROM contacts
WHERE
"projectId" = ${projectId}
AND data IS NOT NULL
AND jsonb_typeof(data) = 'object'
),
field_samples AS (
SELECT
fk.key,
jsonb_typeof(c.data->fk.key) as json_type,
(c.data->>fk.key) as sample_value
FROM field_keys fk
CROSS JOIN LATERAL (
SELECT data
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? fk.key
AND data->fk.key IS NOT NULL
LIMIT 1
) c
),
field_counts AS (
SELECT
fk.key,
COUNT(*) as contact_count
FROM field_keys fk
JOIN contacts c ON c."projectId" = ${projectId}
AND c.data ? fk.key
AND c.data->fk.key IS NOT NULL
GROUP BY fk.key
)
SELECT
fs.key,
fs.sample_value,
fs.json_type,
fc.contact_count
FROM field_samples fs
JOIN field_counts fc ON fc.key = fs.key
`;
// Infer types from JSON types and sample values, calculate coverage
const customFields = result.map(row => {
let type: 'string' | 'number' | 'boolean' | 'date' = 'string';
// PostgreSQL jsonb_typeof returns: "object", "array", "string", "number", "boolean", "null"
if (row.json_type === 'boolean') {
type = 'boolean';
} else if (row.json_type === 'number') {
type = 'number';
} else if (row.json_type === 'string' && row.sample_value) {
// Try to detect dates (ISO 8601 format)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
if (dateRegex.test(row.sample_value)) {
type = 'date';
}
}
// Calculate coverage percentage
const contactCount = Number(row.contact_count);
const coverage = totalContacts > 0 ? Math.round((contactCount / totalContacts) * 100) : 0;
return {
field: `data.${row.key}`,
type,
coverage,
};
});
return [...standardFields, ...customFields].sort((a, b) => a.field.localeCompare(b.field));
}
/**
* Get unique values for a contact field
* Optimized for large datasets (1M+ contacts) - limits results and uses efficient queries
*
* @param projectId - The project ID to filter contacts
* @param field - The field path (e.g., "subscribed", "email", "data.plan", "data.firstName")
* @param limit - Maximum number of unique values to return (default: 100)
* @returns Array of unique values, sorted alphabetically
*/
public static async getUniqueFieldValues(
projectId: string,
field: string,
limit = 100,
): Promise<Array<string | number | boolean>> {
if (field === 'subscribed') {
// Boolean field - return both possible values
return [true, false];
}
if (field === 'email') {
// Email is not useful for dropdowns, return empty
return [];
}
// Handle JSON data fields (e.g., "data.plan" or just "plan")
const jsonField = field.startsWith('data.') ? field.substring(5) : field;
// Use raw SQL for performance with large datasets
// Extract unique values from the JSON field using PostgreSQL's JSON operators
const result = await prisma.$queryRaw<Array<{value: unknown}>>`
SELECT DISTINCT
data->>${jsonField} as value
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
AND data->>${jsonField} IS NOT NULL
AND data->>${jsonField} != ''
ORDER BY value
LIMIT ${limit}
`;
// Parse and return values, handling different data types
return result
.map(row => {
const value = String(row.value);
// Try to parse as boolean
if (value === 'true') return true;
if (value === 'false') return false;
// Try to parse as number
const numValue = Number(value);
if (!isNaN(numValue) && value.trim() !== '') {
return numValue;
}
// Return as string
return value;
})
.filter(v => v !== null && v !== undefined);
}
/**
* Check if a contact field is used in any segments or campaigns
* Returns usage information including which segments/campaigns use the field
*
* @param projectId - The project ID
* @param field - The field to check (e.g., "data.plan", "email", "subscribed")
* @returns Usage information
*/
public static async getFieldUsage(
projectId: string,
field: string,
): Promise<{
usedInSegments: Array<{id: string; name: string}>;
usedInCampaigns: Array<{id: string; name: string}>;
contactCount: number;
canDelete: boolean;
}> {
// Get all segments for the project
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, condition: true},
});
// Check which segments use this field
const usedInSegments = segments.filter(segment => {
const condition = segment.condition as FilterCondition | null;
return this.fieldUsedInCondition(field, condition);
});
// For now, we'll check if campaigns use the field in their subject or body
// This is a simplified check - you might want to enhance this based on your campaign structure
const usedInCampaigns: Array<{id: string; name: string}> = [];
// Count contacts that have this field (for data fields)
let contactCount = 0;
if (field.startsWith('data.')) {
const jsonField = field.substring(5);
const result = await prisma.$queryRaw<Array<{count: bigint}>>`
SELECT COUNT(*) as count
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
AND data->${jsonField} IS NOT NULL
`;
contactCount = Number(result[0]?.count || 0);
} else if (field === 'email' || field === 'subscribed' || field === 'createdAt' || field === 'updatedAt') {
// Standard fields exist on all contacts
const result = await prisma.contact.count({where: {projectId}});
contactCount = result;
}
const canDelete = usedInSegments.length === 0 && usedInCampaigns.length === 0;
return {
usedInSegments: usedInSegments.map(s => ({id: s.id, name: s.name})),
usedInCampaigns,
contactCount,
canDelete,
};
}
/**
* Delete a custom field from all contacts
* WARNING: This is destructive and cannot be undone
* Should only be called after verifying the field is not in use
*
* @param projectId - The project ID
* @param field - The field to delete (must be a data.* field)
*/
public static async deleteField(projectId: string, field: string): Promise<{deletedFrom: number}> {
// Only allow deleting custom data fields
if (!field.startsWith('data.')) {
throw new HttpException(400, 'Can only delete custom data fields (data.*)');
}
// Check if field is in use
const usage = await this.getFieldUsage(projectId, field);
if (!usage.canDelete) {
throw new HttpException(
400,
`Cannot delete field: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInCampaigns.length} campaign(s)`,
);
}
const jsonField = field.substring(5);
// Delete the field from all contacts using raw SQL
// PostgreSQL's `-` operator removes a key from a JSON object
const result = await prisma.$executeRaw`
UPDATE contacts
SET data = data - ${jsonField}
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
`;
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
* Updates multiple contacts to subscribed=true in batches
*/
public static async bulkSubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
// Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
if (unsubscribedIds.length === 0) {
return {updated: 0};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk subscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
if (subscribedIds.length === 0) {
return {updated: 0};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk delete contacts
*/
public static async bulkDelete(
projectId: string,
contactIds: string[],
): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({
where: {
id: {in: contactIds},
projectId,
},
});
return {deleted: result.count};
}
/**
* Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling
*
* @private
*/
private static async trackEventsSequentially(
projectId: string,
eventName: string,
contactIds: string[],
): Promise<void> {
for (const contactId of contactIds) {
try {
await EventService.trackEvent(projectId, eventName, contactId);
} catch (error) {
// Log error but continue processing remaining events
// Suppress logging in test environments to reduce noise from cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
}
}
}
}
}
+289
View File
@@ -0,0 +1,289 @@
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
export class DomainService {
/**
* Get a domain by ID
*/
public static async id(id: string) {
return wrapRedis(Keys.Domain.id(id), async () => {
return prisma.domain.findUnique({where: {id}});
});
}
/**
* Get all domains for a project
*/
public static async getProjectDomains(projectId: string) {
return wrapRedis(Keys.Domain.project(projectId), async () => {
return prisma.domain.findMany({
where: {projectId},
orderBy: {createdAt: 'desc'},
});
});
}
/**
* Add a new domain to a project and start verification
*/
public static async addDomain(projectId: string, domain: string) {
// Start verification process with AWS SES
const dkimTokens = await verifyDomain(domain);
// Create domain record
const newDomain = await prisma.domain.create({
data: {
projectId,
domain,
verified: false,
dkimTokens,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about domain added
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
return newDomain;
}
/**
* Check verification status for a domain
*/
public static async checkVerification(domainId: string) {
const domain = await prisma.domain.findUnique({where: {id: domainId}});
if (!domain) {
throw new Error('Domain not found');
}
const attributes = await getDomainVerificationAttributes(domain.domain);
// Update domain if verification status changed
if (attributes.status === 'Success' && !domain.verified) {
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
data: {verified: true},
include: {
project: {
select: {name: true, id: true},
},
},
});
// Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
} else if (attributes.status !== 'Success' && domain.verified) {
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
data: {verified: false},
include: {
project: {
select: {name: true, id: true},
},
},
});
// Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
}
return {
domain: domain.domain,
tokens: attributes.tokens,
status: attributes.status,
verified: attributes.status === 'Success',
};
}
/**
* Remove a domain from a project
*/
public static async removeDomain(domainId: string) {
const domain = await prisma.domain.findUnique({
where: {id: domainId},
include: {
project: {
select: {name: true, id: true},
},
},
});
if (!domain) {
throw new Error('Domain not found');
}
// Extract domain name for checking usage
const domainName = domain.domain;
// Check if domain is used in any templates
const templatesUsingDomain = await prisma.template.count({
where: {
projectId: domain.projectId,
from: {
contains: `@${domainName}`,
},
},
});
if (templatesUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${templatesUsingDomain} template(s). Update the templates first.`,
);
}
// Check if domain is used in any workflow steps (via templates)
const workflowStepsUsingDomain = await prisma.workflowStep.count({
where: {
workflow: {
projectId: domain.projectId,
},
template: {
from: {
contains: `@${domainName}`,
},
},
},
});
if (workflowStepsUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${workflowStepsUsingDomain} workflow step(s). Update the workflow templates first.`,
);
}
// Check if domain is used in any active campaigns
const campaignsUsingDomain = await prisma.campaign.count({
where: {
projectId: domain.projectId,
from: {
contains: `@${domainName}`,
},
status: {
in: ['DRAFT', 'SCHEDULED', 'SENDING'],
},
},
});
if (campaignsUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${campaignsUsingDomain} active campaign(s). Update or complete the campaigns first.`,
);
}
await prisma.domain.delete({where: {id: domainId}});
// Send notification about domain removal
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
return true;
}
/**
* Get verified domains for a project
*/
public static async getVerifiedDomains(projectId: string) {
return prisma.domain.findMany({
where: {
projectId,
verified: true,
},
});
}
/**
* Verify that an email domain belongs to the specified project and is verified
* @param email Full email address (e.g., "hello@example.com")
* @param projectId Project ID to verify ownership
* @returns The verified domain object
* @throws HttpException if domain not found, not owned by project, or not verified
*/
public static async verifyEmailDomain(email: string, projectId: string) {
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
throw new HttpException(400, 'Invalid email format');
}
const domainName = emailParts[1];
// Find domain in database
const domain = await prisma.domain.findFirst({
where: {
domain: domainName,
},
});
if (!domain) {
throw new HttpException(
403,
`Domain "${domainName}" is not registered. Please add and verify this domain in your project settings.`,
);
}
// Verify domain belongs to the project
if (domain.projectId !== projectId) {
throw new HttpException(
403,
`Domain "${domainName}" belongs to a different project. You cannot use this domain.`,
);
}
// Verify domain is verified
if (!domain.verified) {
throw new HttpException(
403,
`Domain "${domainName}" is not verified. Please complete the DNS verification process in your domain settings.`,
);
}
return domain;
}
/**
* Check if a domain is already linked to another project
* Used when adding a new domain to verify if the user has access to the existing project
* @param domain Domain name to check
* @param userId User ID to check membership
* @returns Object with exists flag and membership info
*/
public static async checkDomainOwnership(domain: string, userId: string) {
const existingDomain = await prisma.domain.findFirst({
where: {domain},
include: {
project: {
include: {
members: {
where: {userId},
},
},
},
},
});
if (!existingDomain) {
return {exists: false};
}
// Check if user is a member of the project that owns this domain
const isMember = existingDomain.project.members.length > 0;
return {
exists: true,
projectId: existingDomain.project.id,
projectName: existingDomain.project.name,
isMember,
};
}
}
+694
View File
@@ -0,0 +1,694 @@
import type {Contact, Email, Prisma, Project} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EventService} from './EventService.js';
import {QueueService} from './QueueService.js';
import {sendRawEmail} from './SESService.js';
interface Attachment {
filename: string;
content: string; // Base64 encoded
contentType: string;
}
interface SendEmailParams {
projectId: string;
contactId: string;
subject: string;
body: string;
from: string;
fromName?: string;
toName?: string;
replyTo?: string;
headers?: Record<string, string>;
attachments?: Attachment[];
templateId?: string;
campaignId?: string;
workflowExecutionId?: string;
workflowStepExecutionId?: string;
}
/**
* Email Service
* Handles sending emails and tracking delivery
*/
export class EmailService {
/**
* Send a transactional email via API
*/
public static async sendTransactionalEmail(params: SendEmailParams): Promise<Email> {
// Check if a template is used and if it's a marketing template
// Marketing templates should not be sent to unsubscribed contacts even via the transactional API
if (params.templateId) {
const template = await prisma.template.findUnique({
where: {id: params.templateId},
select: {type: true},
});
// If using a marketing template, check subscription status
if (template?.type === 'MARKETING') {
const contact = await prisma.contact.findUnique({
where: {id: params.contactId},
select: {subscribed: true, email: true},
});
if (!contact?.subscribed) {
throw new HttpException(
400,
`Cannot send marketing template to unsubscribed contact ${contact?.email || params.contactId}. Use a transactional template or send without a template.`,
);
}
}
}
// Check billing limit before sending
const limitCheck = await BillingLimitService.checkLimit(params.projectId, EmailSourceType.TRANSACTIONAL);
if (!limitCheck.allowed) {
throw new HttpException(429, limitCheck.message || 'Billing limit exceeded for transactional emails');
}
// Log warning if approaching limit (80%)
if (limitCheck.warning) {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
}
const email = await prisma.email.create({
data: {
projectId: params.projectId,
contactId: params.contactId,
subject: params.subject,
body: params.body,
from: params.from,
fromName: params.fromName,
toName: params.toName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
sourceType: EmailSourceType.TRANSACTIONAL,
templateId: params.templateId,
status: EmailStatus.PENDING,
},
});
// Increment usage counter in cache
await BillingLimitService.incrementUsage(params.projectId, EmailSourceType.TRANSACTIONAL);
// Queue email for sending
await this.queueEmail(email.id);
return email;
}
/**
* Send a campaign email
*/
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
// Check if template is transactional to determine source type
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
if (params.templateId) {
const template = await prisma.template.findUnique({
where: {id: params.templateId},
select: {type: true},
});
// If template is marked as TRANSACTIONAL, use TRANSACTIONAL sourceType
// This ensures unsubscribe footer is not added to transactional emails
if (template?.type === 'TRANSACTIONAL') {
sourceType = EmailSourceType.TRANSACTIONAL;
}
}
// Check billing limit before sending
const limitCheck = await BillingLimitService.checkLimit(params.projectId, sourceType);
if (!limitCheck.allowed) {
throw new HttpException(
429,
limitCheck.message || `Billing limit exceeded for ${sourceType.toLowerCase()} emails`,
);
}
// Log warning if approaching limit (80%)
if (limitCheck.warning) {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
}
const email = await prisma.email.create({
data: {
projectId: params.projectId,
contactId: params.contactId,
subject: params.subject,
body: params.body,
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
sourceType,
templateId: params.templateId,
campaignId: params.campaignId,
status: EmailStatus.PENDING,
},
});
// Increment usage counter in cache
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id);
return email;
}
/**
* Send a workflow email
*/
public static async sendWorkflowEmail(params: SendEmailParams): Promise<Email> {
// Check if template is transactional to determine source type
let sourceType: EmailSourceType = EmailSourceType.WORKFLOW;
if (params.templateId) {
const template = await prisma.template.findUnique({
where: {id: params.templateId},
select: {type: true},
});
// If template is marked as TRANSACTIONAL, use TRANSACTIONAL sourceType
// This ensures unsubscribe footer is not added to transactional emails
if (template?.type === 'TRANSACTIONAL') {
sourceType = EmailSourceType.TRANSACTIONAL;
}
}
// Check subscription status for marketing emails
// Transactional emails should always be sent regardless of subscription status
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
const contact = await prisma.contact.findUnique({
where: {id: params.contactId},
select: {subscribed: true},
});
if (!contact?.subscribed) {
signale.info(
`[WORKFLOW] Skipping marketing email to unsubscribed contact ${params.contactId} in workflow execution ${params.workflowExecutionId}`,
);
// For workflows, we silently skip sending to unsubscribed contacts for marketing emails
// Return a placeholder email record that won't be sent
return await prisma.email.create({
data: {
projectId: params.projectId,
contactId: params.contactId,
subject: params.subject,
body: params.body,
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
workflowStepExecutionId: params.workflowStepExecutionId,
status: EmailStatus.FAILED,
error: 'Contact is unsubscribed from marketing emails',
},
});
}
}
// Check billing limit before sending
const limitCheck = await BillingLimitService.checkLimit(params.projectId, sourceType);
if (!limitCheck.allowed) {
throw new HttpException(
429,
limitCheck.message || `Billing limit exceeded for ${sourceType.toLowerCase()} emails`,
);
}
// Log warning if approaching limit (80%)
if (limitCheck.warning) {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
}
const email = await prisma.email.create({
data: {
projectId: params.projectId,
contactId: params.contactId,
subject: params.subject,
body: params.body,
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
workflowStepExecutionId: params.workflowStepExecutionId,
status: EmailStatus.PENDING,
},
});
// Increment usage counter in cache
await BillingLimitService.incrementUsage(params.projectId, sourceType);
// Queue email for sending
await this.queueEmail(email.id);
return email;
}
/**
* Actually send the email via AWS SES
* This is called by the email processor worker
*/
public static async sendEmail(emailId: string): Promise<void> {
const email = await prisma.email.findUnique({
where: {id: emailId},
include: {
contact: true,
project: true,
template: {
select: {type: true},
},
},
});
if (!email) {
throw new HttpException(404, 'Email not found');
}
if (email.status !== EmailStatus.PENDING) {
return; // Already processed
}
// Final validation: Check subscription status before sending
// Only transactional emails should be sent to unsubscribed contacts
if (!email.contact.subscribed) {
const isTransactional =
email.sourceType === EmailSourceType.TRANSACTIONAL || email.template?.type === 'TRANSACTIONAL';
if (!isTransactional) {
signale.warn(`[EMAIL] Skipping marketing email ${emailId} to unsubscribed contact ${email.contact.email}`);
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'Contact is unsubscribed from marketing emails',
},
});
return;
}
}
try {
// Verify domain is registered and verified before sending
// This ensures all emails (transactional, campaign, workflow) use verified domains
await DomainService.verifyEmailDomain(email.from, email.projectId);
// Update status to sending
await prisma.email.update({
where: {id: emailId},
data: {status: EmailStatus.SENDING},
});
// Format template variables in subject and body
const contactData =
email.contact.data && typeof email.contact.data === 'object' && !Array.isArray(email.contact.data)
? email.contact.data
: {};
const formattedEmail = this.format({
subject: email.subject,
body: email.body,
data: {
email: email.contact.email,
...contactData,
data: contactData,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`,
},
});
// Compile HTML with unsubscribe footer and badge
const compiledHtml = this.compile({
content: formattedEmail.body,
contact: email.contact,
project: email.project,
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
});
// Use explicit fromName if provided, otherwise fall back to project name
const fromName = email.fromName || email.project.name;
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;
// Parse attachments from JSON
const attachments =
email.attachments && Array.isArray(email.attachments)
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
: undefined;
// Determine tracking based on project settings and email type
const shouldTrack = this.shouldTrackEmail(email.project.tracking, email.sourceType);
// Send via AWS SES
const result = await sendRawEmail({
from: {
name: fromName,
email: fromEmail,
},
to: [email.contact.email],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: customHeaders,
attachments: attachments,
tracking: shouldTrack,
});
// Mark as sent with SES message ID
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: result.messageId,
},
});
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, 'email.sent', email.contactId, email.id, {
subject: formattedEmail.subject,
from: email.from,
fromName: email.fromName,
messageId: result.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
} catch (error) {
signale.error(`[EMAIL] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: error instanceof Error ? error.message : 'Unknown error',
},
});
throw error;
}
}
/**
* Process email webhook events (opens, clicks, bounces, etc.)
* This would be called by webhook endpoints from your email provider
*/
public static async handleWebhookEvent(
emailId: string,
eventType: 'opened' | 'clicked' | 'bounced' | 'complained' | 'delivered',
metadata?: Record<string, unknown>,
): Promise<void> {
const email = await prisma.email.findUnique({
where: {id: emailId},
});
if (!email) {
throw new HttpException(404, 'Email not found');
}
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
switch (eventType) {
case 'delivered':
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
break;
case 'opened':
if (!email.openedAt) {
updateData.openedAt = now;
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
break;
case 'clicked':
if (!email.clickedAt) {
updateData.clickedAt = now;
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
break;
case 'bounced':
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on bounce and track event
if (email.contactId) {
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
// Track unsubscription event
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
reason: 'bounce',
});
}
break;
case 'complained':
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact and track event
if (email.contactId) {
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
// Track unsubscription event
await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, {
reason: 'complaint',
});
}
break;
}
await prisma.email.update({
where: {id: emailId},
data: updateData,
});
// Track event
await prisma.event.create({
data: {
projectId: email.projectId,
contactId: email.contactId,
emailId: email.id,
name: `email.${eventType}`,
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
},
});
}
/**
* Get email statistics for a project
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date) {
const where: Prisma.EmailWhereInput = {
projectId,
...(startDate || endDate
? {
createdAt: {
...(startDate ? {gte: startDate} : {}),
...(endDate ? {lte: endDate} : {}),
},
}
: {}),
};
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
prisma.email.count({where}),
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
prisma.email.count({where: {...where, status: EmailStatus.FAILED}}),
]);
return {
total,
sent,
delivered,
opened,
clicked,
bounced,
failed,
openRate: sent > 0 ? (opened / sent) * 100 : 0,
clickRate: sent > 0 ? (clicked / sent) * 100 : 0,
bounceRate: sent > 0 ? (bounced / sent) * 100 : 0,
};
}
/**
* Format email template by replacing variables in subject and body
* Uses shared template rendering from @plunk/shared
*/
public static format({subject, body, data}: {subject: string; body: string; data: Record<string, unknown>}): {
subject: string;
body: string;
} {
return {
subject: renderTemplate(subject, data),
body: renderTemplate(body, data),
};
}
/**
* Determine if an email should be tracked based on project tracking mode and email source type
*/
public static shouldTrackEmail(trackingMode: TrackingMode, sourceType: EmailSourceType): boolean {
switch (trackingMode) {
case TrackingMode.ENABLED:
return true;
case TrackingMode.DISABLED:
return false;
case TrackingMode.MARKETING_ONLY:
// Track only campaigns and workflows (marketing), not transactional emails
return sourceType !== EmailSourceType.TRANSACTIONAL;
default:
return true;
}
}
/**
* Compile HTML email with optional unsubscribe footer and badge
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
*/
public static compile({
content,
contact,
project,
includeUnsubscribe = true,
}: {
content: string;
contact: Contact;
project: Project;
includeUnsubscribe?: boolean;
}): string {
let html = content;
const unsubscribeHtml = includeUnsubscribe
? (() => {
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
// Get translator for contact's locale or project's language
const translator = createTranslatorSync(contactLocale || project.language || 'en');
const unsubscribeText = translator.t('email.footer.unsubscribeText', {
projectName: project.name,
});
const updatePreferencesText = translator.t('email.footer.updatePreferences');
return `<table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
<tbody>
<tr>
<td>
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
${unsubscribeText}
<a href="${DASHBOARD_URI}/unsubscribe/${contact.id}">${updatePreferencesText}</a>.
</p>
</td>
</tr>
</tbody>
</table>`;
})()
: '';
// Add Plunk badge if billing is enabled and project has no subscription (free tier)
const badgeHtml =
STRIPE_ENABLED && project.subscription === null
? `<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:180px;">
<a href="${LANDING_URI}?ref=badge" target="_blank">
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>`
: '';
// Combine footer and badge
const footerHtml = `${unsubscribeHtml}${badgeHtml}`;
// Insert before closing body tag if it exists, otherwise append
if (html.includes('</body>')) {
html = html.replace('</body>', `${footerHtml}</body>`);
} else {
html = `${html}${footerHtml}`;
}
return html;
}
/**
* Queue an email for sending
* Adds email to the BullMQ queue for processing by workers
*/
private static async queueEmail(emailId: string, delay?: number): Promise<void> {
await QueueService.queueEmail(emailId, delay);
}
}
@@ -0,0 +1,160 @@
import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker';
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 =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null;
/**
* Fetch and cache the disposable domains list from GitHub
* Uses Redis for caching with 24-hour TTL
* Falls back to in-memory cache if Redis fails
*/
private static async getDisposableDomains(): Promise<Set<string>> {
// Return in-memory cache if available
if (this.disposableDomainsSet) {
return this.disposableDomainsSet;
}
try {
// Try to get from Redis cache first
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
if (cached) {
const domains = JSON.parse(cached) as string[];
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
}
// Fetch from GitHub if not in cache
const response = await fetch(DISPOSABLE_DOMAINS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
} catch (error) {
console.error('Error fetching disposable domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
const disposableDomains = await this.getDisposableDomains();
return disposableDomains.has(domain.toLowerCase());
}
/**
* Verify an email address
* - 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> {
const result: EmailVerificationResult = {
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
// Check if email is from a disposable domain using GitHub list
result.isDisposable = await this.isDisposableDomain(domain);
// Check for plus addressing
result.isPlusAddressed = emailParts[0]!.includes('+');
// Check for common typos and suggest corrections
const typoCheck = run({email});
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
result.suggestedEmail = typoCheck.full;
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
result.isTypo = true;
}
// Check if domain exists (has any DNS records)
try {
await dns.resolve(domain, 'A');
result.domainExists = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.domainExists = true;
} catch {
result.domainExists = false;
result.valid = false;
result.reasons.push('Domain does not exist');
}
}
// Check MX records (only if domain exists)
if (result.domainExists) {
try {
const mxRecords = await dns.resolveMx(domain);
result.hasMxRecords = mxRecords && mxRecords.length > 0;
if (!result.hasMxRecords) {
result.valid = false;
result.reasons.push('No MX records found for domain');
}
} catch {
result.hasMxRecords = false;
result.valid = false;
result.reasons.push('No MX records found for domain');
}
}
// If no issues were found, add a success reason
if (result.valid && result.reasons.length === 0) {
result.reasons.push('Email appears to be valid');
}
return result;
}
}
+535
View File
@@ -0,0 +1,535 @@
import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
/**
* Event Service
* Handles event tracking and workflow triggering
*/
export class EventService {
/**
* Track an event
* This can trigger workflows that are listening for this event
*/
public static async trackEvent(
projectId: string,
eventName: string,
contactId?: string,
emailId?: string,
data?: Record<string, unknown>,
): Promise<Event> {
// Create event record
const event = await prisma.event.create({
data: {
projectId,
contactId,
emailId,
name: eventName,
data: data ? (data as Prisma.InputJsonValue) : undefined,
},
});
// Trigger workflows that are listening for this event
await this.triggerWorkflows(projectId, eventName, contactId, data);
// Resume workflows waiting for this event
await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data);
return event;
}
/**
* Invalidate the workflow cache for a project
* Should be called when workflows are enabled/disabled or updated
*/
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = Keys.Workflow.enabled(projectId);
try {
await redis.del(cacheKey);
} catch (error) {
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
}
}
/**
* Get events for a contact
*/
public static async getContactEvents(projectId: string, contactId: string, limit = 50): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
contactId,
},
orderBy: {createdAt: 'desc'},
take: limit,
});
}
/**
* Get events for a project
*/
public static async getProjectEvents(projectId: string, eventName?: string, limit = 100): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
}
/**
* Get event counts by type
*/
public static async getEventStats(projectId: string, startDate?: Date, endDate?: Date) {
const where: Prisma.EventWhereInput = {
projectId,
...(startDate || endDate
? {
createdAt: {
...(startDate ? {gte: startDate} : {}),
...(endDate ? {lte: endDate} : {}),
},
}
: {}),
};
const events = await prisma.event.groupBy({
by: ['name'],
where,
_count: true,
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => ({
name: e.name,
count: e._count,
}));
}
/**
* Get unique event names for a project
*/
public static async getUniqueEventNames(projectId: string): Promise<string[]> {
const events = await prisma.event.groupBy({
by: ['name'],
where: {projectId},
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => e.name);
}
/**
* Get available event data fields for a specific event name
* Analyzes actual event data to discover which fields are present
* This is optimized for large datasets - only samples recent events
*/
public static async getAvailableEventFields(projectId: string, eventName?: string): Promise<string[]> {
// Query recent events to discover data fields (limit to 100 for performance)
const events = await prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
data: {
not: Prisma.DbNull, // Only events with data (not null)
},
},
select: {
data: true,
},
orderBy: {createdAt: 'desc'},
take: 100, // Sample recent events for performance
});
// Extract all unique keys from event data
const fieldSet = new Set<string>();
for (const event of events) {
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
const data = event.data as Record<string, unknown>;
for (const key of Object.keys(data)) {
fieldSet.add(`event.${key}`);
}
}
}
return Array.from(fieldSet).sort();
}
/**
* Check if an event is used in any segments or workflows
* Returns usage information including which segments/workflows use the event
*
* @param projectId - The project ID
* @param eventName - The event name to check (e.g., "purchase.completed", "user.signup")
* @returns Usage information
*/
public static async getEventUsage(
projectId: string,
eventName: string,
): Promise<{
usedInSegments: Array<{id: string; name: string}>;
usedInWorkflows: Array<{id: string; name: string}>;
totalCount: number;
uniqueContacts: number;
canDelete: boolean;
}> {
// Get all segments for the project
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, condition: true},
});
// Check which segments use this event
const usedInSegments = segments.filter(segment => {
const condition = segment.condition as FilterCondition | null;
return this.eventUsedInCondition(eventName, condition);
});
// Get workflows that use this event as a trigger or wait condition
const workflows = await prisma.workflow.findMany({
where: {
projectId,
OR: [
// Event as trigger
{
triggerType: 'EVENT',
triggerConfig: {
path: ['eventName'],
equals: eventName,
},
},
],
},
select: {id: true, name: true},
});
// Also check workflow steps that wait for events
const workflowStepsWithEvent = await prisma.workflowStep.findMany({
where: {
workflow: {projectId},
type: 'WAIT_FOR_EVENT',
config: {
path: ['eventName'],
equals: eventName,
},
},
include: {
workflow: {
select: {id: true, name: true},
},
},
});
const usedInWorkflows = [...workflows, ...workflowStepsWithEvent.map(step => step.workflow)].reduce(
(acc, workflow) => {
// Deduplicate by id
if (!acc.find((w: {id: string; name: string}) => w.id === workflow.id)) {
acc.push(workflow);
}
return acc;
},
[] as Array<{id: string; name: string}>,
);
// Get event statistics
const [totalCount, uniqueContacts] = await Promise.all([
prisma.event.count({
where: {projectId, name: eventName},
}),
prisma.event
.groupBy({
by: ['contactId'],
where: {projectId, name: eventName, contactId: {not: null}},
})
.then(results => results.length),
]);
const canDelete = usedInSegments.length === 0 && usedInWorkflows.length === 0;
return {
usedInSegments,
usedInWorkflows,
totalCount,
uniqueContacts,
canDelete,
};
}
/**
* Delete all events with a specific name
* WARNING: This is destructive and cannot be undone
* Should only be called after verifying the event is not in use
*
* @param projectId - The project ID
* @param eventName - The event name to delete
*/
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of reserved system events
if (this.isReservedEvent(eventName)) {
throw new Error(`Cannot delete reserved system event: ${eventName}`);
}
// Check if event is in use
const usage = await this.getEventUsage(projectId, eventName);
if (!usage.canDelete) {
throw new Error(
`Cannot delete event: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInWorkflows.length} workflow(s)`,
);
}
// Delete all events with this name
const result = await prisma.event.deleteMany({
where: {
projectId,
name: eventName,
},
});
return {deletedCount: result.count};
}
/**
* Check if an event name is reserved for system use
* Reserved patterns:
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
* - contact.subscribed, contact.unsubscribed
* - segment.*.entry, segment.*.exit
*
* @param eventName - The event name to check
* @returns true if the event is reserved, false otherwise
*/
public static isReservedEvent(eventName: string): boolean {
// Email events: email.*
if (eventName.startsWith('email.')) {
return true;
}
// Contact events: contact.subscribed, contact.unsubscribed
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
return true;
}
// Segment events: segment.*.entry, segment.*.exit
// Pattern: segment.<slug>.entry or segment.<slug>.exit
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
return true;
}
return false;
}
/**
* Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance
*/
private static async triggerWorkflows(
projectId: string,
eventName: string,
contactId?: string,
data?: Record<string, unknown>,
): Promise<void> {
// Try to get workflows from cache
const cacheKey = Keys.Workflow.enabled(projectId);
let workflows;
try {
const cached = await redis.get(cacheKey);
if (cached) {
workflows = JSON.parse(cached);
}
} catch (error) {
signale.warn('[EVENT] Failed to get workflows from cache:', error);
}
// If not in cache, fetch from database
if (!workflows) {
workflows = await prisma.workflow.findMany({
where: {
projectId,
enabled: true,
triggerType: 'EVENT',
},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
// Cache for 5 minutes
try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) {
signale.warn('[EVENT] Failed to cache workflows:', error);
}
}
for (const workflow of workflows) {
const triggerConfig = workflow.triggerConfig;
// Check if this workflow is triggered by this event
if (triggerConfig?.eventName === eventName) {
// If event is for a specific contact, start workflow for that contact
if (contactId) {
await this.startWorkflowForContact(workflow.id, contactId, data);
} else {
// If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
}
}
}
}
/**
* Start a workflow execution for a contact
*/
private static async startWorkflowForContact(
workflowId: string,
contactId: string,
context?: Record<string, unknown>,
): Promise<void> {
try {
// Get workflow with steps and configuration
const workflow = await prisma.workflow.findUnique({
where: {id: workflowId},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
if (!workflow || workflow.steps.length === 0) {
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
return;
}
// Check re-entry rules
if (!workflow.allowReentry) {
// If re-entry is not allowed, check if contact has ANY execution (regardless of status)
const existingExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
},
});
if (existingExecution) {
return;
}
} else {
// If re-entry is allowed, only check if there's a currently RUNNING execution
const runningExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
status: 'RUNNING',
},
});
if (runningExecution) {
return;
}
}
const triggerStep = workflow.steps[0];
if (!triggerStep) {
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return;
}
// Create workflow execution
const execution = await prisma.workflowExecution.create({
data: {
workflowId,
contactId,
status: 'RUNNING',
currentStepId: triggerStep.id,
context: context ? (context as Prisma.InputJsonValue) : undefined,
},
});
signale.info(
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
);
// Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) {
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
}
}
/**
* Helper: Check if an event is used in a filter condition (recursive)
*/
private static eventUsedInCondition(eventName: 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.eventUsedInGroup(eventName, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if an event is used in a filter group (recursive)
*/
private static eventUsedInGroup(eventName: 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) {
// Event filters use field name like "event.eventName"
if (filter.field === `event.${eventName}`) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.eventUsedInCondition(eventName, group.conditions);
}
return false;
}
}
+98
View File
@@ -0,0 +1,98 @@
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
/**
* Meter Service
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
*/
export class MeterService {
/**
* Record an email sent event to Stripe meter
* This is used for usage-based billing where customers pay per email sent
*
* @param customerId - Stripe customer ID
* @param value - Number of emails sent (default: 1)
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
*/
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
// Skip if billing is disabled (self-hosted mode)
if (!STRIPE_ENABLED || !stripe) {
return;
}
// Skip if no customer ID (not subscribed)
if (!customerId) {
return;
}
try {
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(), // Must be a string
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
} catch (error) {
// Log error but don't throw - we don't want billing issues to break email sending
signale.error('[METER] Failed to record email sent event:', error);
// If it's a rate limit error, we might want to retry
if (error instanceof Error && error.message.includes('429')) {
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
}
}
}
/**
* Record multiple emails in a single batch (for campaign sending)
* More efficient than individual calls when sending bulk emails
*
* @param customerId - Stripe customer ID
* @param count - Number of emails sent in this batch
* @param batchId - Unique identifier for this batch
*/
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
if (count <= 0) return;
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
}
/**
* Get current usage for a customer in the current billing period
* Useful for displaying usage dashboards or implementing soft limits
*
* @param customerId - Stripe customer ID
* @param meterId - The meter ID from Stripe dashboard
* @param startTime - Start of period (Unix timestamp)
* @param endTime - End of period (Unix timestamp)
*/
public static async getUsageSummary(
meterId: string,
customerId: string,
startTime: number,
endTime: number,
): Promise<unknown> {
if (!STRIPE_ENABLED || !stripe) {
return null;
}
try {
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
customer: customerId,
start_time: startTime,
end_time: endTime,
});
return summaries;
} catch (error) {
signale.error('[METER] Failed to get usage summary:', error);
return null;
}
}
}
+779
View File
@@ -0,0 +1,779 @@
import signale from 'signale';
/**
* Priority levels for ntfy notifications
* Based on ntfy.sh documentation
*/
export enum NtfyPriority {
MIN = 1, // No vibration/sound, relegated to "Other notifications"
LOW = 2, // No vibration/sound, hidden until drawer opened
DEFAULT = 3, // Short vibration and sound (standard)
HIGH = 4, // Long vibration, pop-over notification
MAX = 5, // Long vibration bursts, pop-over notification
}
/**
* Tags for ntfy notifications (emoji shortcuts)
*/
export enum NtfyTag {
WARNING = 'warning',
ERROR = 'rotating_light',
SUCCESS = 'white_check_mark',
MONEY = 'money_with_wings',
SHIELD = 'shield',
ROCKET = 'rocket',
BELL = 'bell',
CHART = 'chart_with_upwards_trend',
SKULL = 'skull',
INFO = 'information_source',
}
export interface NtfyNotification {
title: string;
message: string;
priority?: NtfyPriority;
tags?: NtfyTag[];
}
/**
* Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable
*/
export class NtfyService {
private static ntfyUrl: string | null = null;
private static isConfigured = false;
/**
* Send a notification to ntfy
* @param notification - The notification details
* @returns Promise<void>
*/
public static async send(notification: NtfyNotification): Promise<void> {
this.initialize();
if (!this.ntfyUrl) {
// Silently skip if not configured
return;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
'Title': notification.title,
};
if (notification.priority) {
headers.Priority = notification.priority.toString();
}
if (notification.tags && notification.tags.length > 0) {
headers.Tags = notification.tags.join(',');
}
const response = await fetch(this.ntfyUrl, {
method: 'POST',
headers,
body: notification.message,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
signale.success(`[NTFY] Sent notification: ${notification.title}`);
} catch (error) {
signale.error('[NTFY] Failed to send notification:', error);
// Don't throw - notifications should not break the main flow
}
}
/**
* Send a high-priority notification
*/
public static async sendHigh(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.HIGH,
tags,
});
}
/**
* Send a max-priority urgent notification
*/
public static async sendUrgent(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.MAX,
tags,
});
}
/**
* Send a low-priority informational notification
*/
public static async sendInfo(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.LOW,
tags,
});
}
/**
* Send a default priority notification
*/
public static async sendDefault(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.DEFAULT,
tags,
});
}
/**
* Notify about a new project creation
*/
public static async notifyProjectCreated(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault(
'New Project Created',
`Project "${projectName}" (${projectId}) was created by user ${userId}`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
// ===== Event-specific notification helpers =====
/**
* Notify about subscription started
*/
public static async notifySubscriptionStarted(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Started',
`Project "${projectName}" (${projectId}) started a paid subscription (${subscriptionId})`,
[NtfyTag.MONEY, NtfyTag.SUCCESS],
);
}
/**
* Notify about subscription cancelled
*/
public static async notifySubscriptionCancelled(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Cancelled',
`Project "${projectName}" (${projectId}) cancelled their subscription (${subscriptionId})`,
[NtfyTag.WARNING, NtfyTag.MONEY],
);
}
/**
* Notify about project disabled due to security risk
*/
public static async notifyProjectDisabledForSecurity(
projectName: string,
projectId: string,
violations: string[],
): Promise<void> {
const violationText = violations.join(', ');
await this.sendUrgent(
'Project Disabled - Security Risk',
`Project "${projectName}" (${projectId}) was automatically disabled due to: ${violationText}`,
[NtfyTag.SHIELD, NtfyTag.ERROR, NtfyTag.SKULL],
);
}
/**
* Notify about payment failure
*/
public static async notifyPaymentFailed(projectName: string, projectId: string): Promise<void> {
await this.sendHigh('Payment Failed', `Payment failed for project "${projectName}" (${projectId})`, [
NtfyTag.WARNING,
NtfyTag.MONEY,
]);
}
/**
* Notify about successful invoice payment - MIN priority (routine)
*/
public static async notifyInvoicePaid(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Invoice Paid',
message: `Invoice paid for project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.MONEY, NtfyTag.SUCCESS],
});
}
/**
* Notify about subscription update - LOW priority (minor changes)
*/
public static async notifySubscriptionUpdated(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Subscription Updated',
message: `Subscription updated for project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.MONEY, NtfyTag.INFO],
});
}
/**
* Notify about project deletion - DEFAULT priority
*/
public static async notifyProjectDeleted(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault('Project Deleted', `Project "${projectName}" (${projectId}) was deleted by user ${userId}`, [
NtfyTag.WARNING,
]);
}
/**
* Notify about security warning (non-critical)
*/
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
const warningText = warnings.join(', ');
await this.sendDefault(
'Security Warning',
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about email bounce - LOW priority (high volume)
* Rate-limited to only send notification every 20 bounces with per-project breakdown
*/
public static async notifyEmailBounce(
projectName: string,
projectId: string,
recipientEmail: string,
bounceType?: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
// Use Redis counters to track bounce count globally and per-project
const globalCountKey = `ntfy:bounce:count`;
const projectCountKey = `ntfy:bounce:count:${projectId}`;
const projectListKey = `ntfy:bounce:projects`;
// Increment global counter
const globalCount = await redis.incr(globalCountKey);
// Increment project-specific counter
await redis.incr(projectCountKey);
// Add project to the set of projects with bounces (for tracking)
await redis.sadd(projectListKey, projectId);
// Set expiry on first increment (24 hour rolling window)
if (globalCount === 1) {
await redis.expire(globalCountKey, 86400);
await redis.expire(projectListKey, 86400);
}
// Always refresh project counter expiry to match global window
await redis.expire(projectCountKey, 86400);
// Only send notification every 20 bounces
if (globalCount % 20 === 0) {
// Get all projects with bounces
const projectIds = await redis.smembers(projectListKey);
// Get bounce counts for each project
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
for (const pid of projectIds) {
const count = await redis.get(`ntfy:bounce:count:${pid}`);
if (count) {
// Fetch project name from database
const {prisma} = await import('../database/prisma.js');
const project = await prisma.project.findUnique({
where: {id: pid},
select: {name: true},
});
projectCounts.push({
projectId: pid,
count: parseInt(count, 10),
name: project?.name || 'Unknown',
});
}
}
// Sort by count descending
projectCounts.sort((a, b) => b.count - a.count);
// Build breakdown message
const breakdown = projectCounts.map(p => `${p.name} (${p.projectId}): ${p.count}`).join('\n');
const bounceInfo = bounceType ? ` (${bounceType})` : '';
await this.send({
title: 'Email Bounces',
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
}
/**
* Notify about email complaint - HIGH priority (reputation risk)
*/
public static async notifyEmailComplaint(
projectName: string,
projectId: string,
recipientEmail: string,
): Promise<void> {
await this.sendHigh(
'Email Complaint',
`Email complaint received from ${recipientEmail} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.ERROR],
);
}
/**
* Notify about new user account created via signup - LOW priority
*/
public static async notifyUserSignup(userEmail: string, userId: string): Promise<void> {
await this.send({
title: 'New User Signup',
message: `New user registered: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
/**
* Notify about new user account created via OAuth - LOW priority
*/
public static async notifyUserOAuthSignup(
userEmail: string,
userId: string,
provider: 'GitHub' | 'Google',
): Promise<void> {
await this.send({
title: `New User - ${provider} OAuth`,
message: `New user registered via ${provider}: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
/**
* Notify about campaign created (draft) - LOW priority
*/
public static async notifyCampaignCreated(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Created',
message: `Campaign "${campaignName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
// ===== Campaign event notifications =====
/**
* Notify about campaign scheduled - DEFAULT priority
*/
public static async notifyCampaignScheduled(
campaignName: string,
projectName: string,
projectId: string,
scheduledFor: Date,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Scheduled',
`Campaign "${campaignName}" scheduled to send to ${recipientCount} recipients at ${scheduledFor.toISOString()} in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.INFO],
);
}
/**
* Notify about campaign sending started - LOW priority (informational)
*/
public static async notifyCampaignSendingStarted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.send({
title: 'Campaign Sending Started',
message: `Campaign "${campaignName}" started sending to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.CHART],
});
}
/**
* Notify about campaign send completed - DEFAULT priority
*/
public static async notifyCampaignSendCompleted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Send Completed',
`Campaign "${campaignName}" successfully sent to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
/**
* Notify about campaign cancelled - LOW priority
*/
public static async notifyCampaignCancelled(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Cancelled',
message: `Campaign "${campaignName}" was cancelled in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
/**
* Notify about campaign deleted - LOW priority
*/
public static async notifyCampaignDeleted(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Deleted',
message: `Campaign "${campaignName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about workflow created - LOW priority
*/
public static async notifyWorkflowCreated(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Created',
message: `Workflow "${workflowName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
// ===== Workflow event notifications =====
/**
* Notify about workflow enabled
*/
public static async notifyWorkflowEnabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Enabled',
`Workflow "${workflowName}" is now active in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS],
);
}
/**
* Notify about workflow disabled
*/
public static async notifyWorkflowDisabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Disabled',
`Workflow "${workflowName}" has been disabled in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING],
);
}
/**
* Notify about workflow deleted - LOW priority
*/
public static async notifyWorkflowDeleted(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Deleted',
message: `Workflow "${workflowName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about workflow execution failed
*/
public static async notifyWorkflowExecutionFailed(
workflowName: string,
projectName: string,
projectId: string,
contactEmail: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Workflow Execution Failed',
`Workflow "${workflowName}" failed for contact ${contactEmail} in project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR, NtfyTag.WARNING],
);
}
/**
* Notify about domain added
*/
public static async notifyDomainAdded(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Added',
`Domain "${domain}" added for verification in project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
// ===== Domain verification notifications =====
/**
* Notify about domain verified
*/
public static async notifyDomainVerified(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Verified',
`Domain "${domain}" is now verified and ready for use in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS, NtfyTag.SHIELD],
);
}
/**
* Notify about domain verification failed
*/
public static async notifyDomainVerificationFailed(
domain: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendHigh(
'Domain Verification Failed',
`Domain "${domain}" failed verification in project "${projectName}" (${projectId}). Email sending may be affected.`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about domain removed
*/
public static async notifyDomainRemoved(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendInfo(
'Domain Removed',
`Domain "${domain}" was removed from project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
/**
* Notify about billing limit approaching (80% threshold)
*/
public static async notifyBillingLimitApproaching(
projectName: string,
projectId: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
await this.sendDefault(
'Billing Limit Warning',
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
);
}
// ===== Billing and usage limit notifications =====
/**
* Notify about billing limit exceeded - MAX priority (blocks operations)
*/
public static async notifyBillingLimitExceeded(
projectName: string,
projectId: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
await this.sendUrgent(
'Billing Limit Exceeded',
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
);
}
/**
* Notify about API keys regenerated
*/
public static async notifyApiKeysRegenerated(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendHigh(
'API Keys Regenerated',
`API keys for project "${projectName}" (${projectId}) were regenerated by user ${userId}`,
[NtfyTag.SHIELD, NtfyTag.WARNING],
);
}
// ===== API key notifications =====
/**
* Notify about contact import started - MIN priority (routine, high volume)
*/
public static async notifyContactImportStarted(
projectName: string,
projectId: string,
filename: string,
totalRows: number,
): Promise<void> {
await this.send({
title: 'Contact Import Started',
message: `Importing ${totalRows} contacts from "${filename}" into project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.ROCKET],
});
}
// ===== Contact import notifications =====
/**
* Notify about contact import completed
*/
public static async notifyContactImportCompleted(
projectName: string,
projectId: string,
filename: string,
successCount: number,
createdCount: number,
updatedCount: number,
failureCount: number,
): Promise<void> {
await this.sendDefault(
'Contact Import Completed',
`Import "${filename}" completed for project "${projectName}" (${projectId}). Success: ${successCount} (${createdCount} new, ${updatedCount} updated), Errors: ${failureCount}`,
[NtfyTag.SUCCESS, NtfyTag.CHART],
);
}
/**
* Notify about contact import failed
*/
public static async notifyContactImportFailed(
projectName: string,
projectId: string,
filename: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Contact Import Failed',
`Contact import "${filename}" failed for project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR],
);
}
/**
* Notify about segment created - MIN priority
*/
public static async notifySegmentCreated(segmentName: string, projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Segment Created',
message: `Segment "${segmentName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
// ===== Segment notifications =====
/**
* Notify about segment membership computed - LOW priority
*/
public static async notifySegmentMembershipComputed(
segmentName: string,
projectName: string,
projectId: string,
memberCount: number,
added?: number,
removed?: number,
): Promise<void> {
let message = `Segment "${segmentName}" in project "${projectName}" (${projectId}) now has ${memberCount} members`;
if (added !== undefined && removed !== undefined) {
const changes: string[] = [];
if (added > 0) changes.push(`+${added} added`);
if (removed > 0) changes.push(`-${removed} removed`);
if (changes.length > 0) {
message += ` (${changes.join(', ')})`;
}
}
await this.send({
title: 'Segment Membership Updated',
message,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/**
* Notify about segment deleted - MIN priority
*/
public static async notifySegmentDeleted(segmentName: string, projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Segment Deleted',
message: `Segment "${segmentName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
/**
* Initialize the ntfy service with the configured URL
*/
private static initialize(): void {
if (this.isConfigured) {
return;
}
this.ntfyUrl = process.env.NTFY_URL || null;
this.isConfigured = true;
if (!this.ntfyUrl) {
signale.warn('[NTFY] NTFY_URL not configured - notifications will be skipped');
} else {
signale.info(`[NTFY] Initialized with URL: ${this.ntfyUrl}`);
}
}
}
+588
View File
@@ -0,0 +1,588 @@
import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
/**
* Queue Job Data Types
*/
export interface SendEmailJobData {
emailId: string;
}
export interface CampaignBatchJobData {
campaignId: string;
batchNumber: number;
offset: number;
limit: number;
cursor?: string; // For cursor-based pagination
}
export interface WorkflowStepJobData {
executionId: string;
stepId: string;
type?: 'process-step' | 'timeout'; // Job type for different handling
stepExecutionId?: string; // For timeout jobs, reference to the step execution
}
export interface ScheduledCampaignJobData {
campaignId: string;
}
export interface ContactImportJobData {
projectId: string;
csvData: string; // Base64 encoded CSV content
filename: string;
}
export interface SegmentCountJobData {
projectId?: string; // Optional: if provided, only update this project's segments
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DomainVerificationJobData {
// Empty for now - processes all domains
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ApiRequestCleanupJobData {
// Empty - cleans up old API request logs
}
export interface BulkContactActionJobData {
projectId: string;
contactIds: string[];
operation: 'subscribe' | 'unsubscribe' | 'delete';
}
/**
* Queue Configuration
*/
const redisConnection: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
// Parse Redis URL
...parseRedisUrl(REDIS_URL),
};
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
const urlObj = new URL(url);
return {
host: urlObj.hostname,
port: parseInt(urlObj.port || '6379', 10),
password: urlObj.password || undefined,
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
};
}
/**
* Queue Instances
*/
export const emailQueue = new Queue<SendEmailJobData>('email', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 1000, // Keep last 1000 completed jobs
removeOnFail: 5000, // Keep last 5000 failed jobs
},
});
export const campaignQueue = new Queue<CampaignBatchJobData>('campaign', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
});
export const workflowQueue = new Queue<WorkflowStepJobData>('workflow', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 1000,
removeOnFail: 5000,
},
});
export const scheduledQueue = new Queue<ScheduledCampaignJobData>('scheduled', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
});
export const importQueue = new Queue<ContactImportJobData>('import', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for imports
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed imports
removeOnFail: 100, // Keep last 100 failed imports
},
});
export const segmentCountQueue = new Queue<SegmentCountJobData>('segment-count', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 10, // Keep last 10 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
},
});
export const domainVerificationQueue = new Queue<DomainVerificationJobData>('domain-verification', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 10, // Keep last 10 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
},
});
export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-request-cleanup', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 30000,
},
removeOnComplete: 5, // Keep last 5 completed jobs
removeOnFail: 20, // Keep last 20 failed jobs
},
});
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for bulk operations
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed bulk operations
removeOnFail: 100, // Keep last 100 failed bulk operations
},
});
/**
* Queue Service - Centralized queue management
*/
export class QueueService {
/**
* Add email to queue for sending
*/
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
return emailQueue.add(
'send-email',
{emailId},
{
delay, // Optional delay in milliseconds
jobId: `email-${emailId}`, // Prevent duplicate jobs
},
);
}
/**
* Add campaign batch to queue for processing
*/
public static async queueCampaignBatch(data: CampaignBatchJobData): Promise<Job<CampaignBatchJobData>> {
return campaignQueue.add('process-batch', data, {
jobId: `campaign-${data.campaignId}-batch-${data.batchNumber}`,
});
}
/**
* Add workflow step to queue for execution
*/
public static async queueWorkflowStep(
executionId: string,
stepId: string,
delay?: number,
): Promise<Job<WorkflowStepJobData>> {
return workflowQueue.add(
'process-step',
{executionId, stepId, type: 'process-step'},
{
delay,
jobId: `workflow-${executionId}-${stepId}`,
},
);
}
/**
* Queue a timeout handler for WAIT_FOR_EVENT steps
*/
public static async queueWorkflowTimeout(
executionId: string,
stepId: string,
stepExecutionId: string,
timeoutMs: number,
): Promise<Job<WorkflowStepJobData>> {
return workflowQueue.add(
'timeout',
{executionId, stepId, stepExecutionId, type: 'timeout'},
{
delay: timeoutMs,
jobId: `workflow-timeout-${stepExecutionId}`,
},
);
}
/**
* Cancel a queued timeout job
*/
public static async cancelWorkflowTimeout(stepExecutionId: string): Promise<void> {
const jobId = `workflow-timeout-${stepExecutionId}`;
const job = await workflowQueue.getJob(jobId);
if (job) {
await job.remove();
signale.info(`[QUEUE] Cancelled timeout job ${jobId}`);
}
}
/**
* Schedule campaign for future sending
*/
public static async scheduleCampaign(campaignId: string, scheduledFor: Date): Promise<Job<ScheduledCampaignJobData>> {
const delay = scheduledFor.getTime() - Date.now();
return scheduledQueue.add(
'send-scheduled-campaign',
{campaignId},
{
delay: Math.max(0, delay),
jobId: `scheduled-campaign-${campaignId}`,
},
);
}
/**
* Cancel scheduled campaign
*/
public static async cancelScheduledCampaign(campaignId: string): Promise<void> {
const jobId = `scheduled-campaign-${campaignId}`;
const job = await scheduledQueue.getJob(jobId);
if (job) {
await job.remove();
}
}
/**
* Queue contact import job
*/
public static async queueImport(
projectId: string,
csvData: string,
filename: string,
): Promise<Job<ContactImportJobData>> {
return importQueue.add(
'import-contacts',
{projectId, csvData, filename},
{
jobId: `import-${projectId}-${Date.now()}`,
},
);
}
/**
* Get import job status and progress
*/
public static async getImportJobStatus(jobId: string) {
const job = await importQueue.getJob(jobId);
if (!job) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue bulk contact action job
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
);
}
/**
* Get bulk action job status and progress
*/
public static async getBulkActionJobStatus(jobId: string) {
const job = await bulkContactQueue.getJob(jobId);
if (!job) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue segment count update job
*/
public static async queueSegmentCountUpdate(projectId?: string): Promise<Job<SegmentCountJobData>> {
return segmentCountQueue.add(
'update-segment-counts',
{projectId},
{
jobId: projectId ? `segment-count-${projectId}-${Date.now()}` : `segment-count-all-${Date.now()}`,
},
);
}
/**
* Get queue statistics
*/
public static async getStats() {
const [
emailCounts,
campaignCounts,
workflowCounts,
scheduledCounts,
importCounts,
segmentCountCounts,
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
workflowQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
scheduledQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
importQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
email: emailCounts,
campaign: campaignCounts,
workflow: workflowCounts,
scheduled: scheduledCounts,
import: importCounts,
segmentCount: segmentCountCounts,
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
};
}
/**
* Pause all queues (for maintenance)
*/
public static async pauseAll(): Promise<void> {
await Promise.all([
emailQueue.pause(),
campaignQueue.pause(),
workflowQueue.pause(),
scheduledQueue.pause(),
importQueue.pause(),
segmentCountQueue.pause(),
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
]);
}
/**
* Resume all queues
*/
public static async resumeAll(): Promise<void> {
await Promise.all([
emailQueue.resume(),
campaignQueue.resume(),
workflowQueue.resume(),
scheduledQueue.resume(),
importQueue.resume(),
segmentCountQueue.resume(),
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
]);
}
/**
* Clean old jobs (should be run periodically)
*/
public static async cleanOldJobs(): Promise<void> {
const gracePeriod = 24 * 60 * 60 * 1000; // 24 hours
await Promise.all([
emailQueue.clean(gracePeriod, 1000, 'completed'),
emailQueue.clean(gracePeriod * 7, 1000, 'failed'), // Keep failed jobs for 7 days
campaignQueue.clean(gracePeriod, 100, 'completed'),
campaignQueue.clean(gracePeriod * 7, 500, 'failed'),
workflowQueue.clean(gracePeriod, 1000, 'completed'),
workflowQueue.clean(gracePeriod * 7, 1000, 'failed'),
scheduledQueue.clean(gracePeriod, 100, 'completed'),
scheduledQueue.clean(gracePeriod * 7, 500, 'failed'),
importQueue.clean(gracePeriod, 50, 'completed'),
importQueue.clean(gracePeriod * 7, 100, 'failed'),
segmentCountQueue.clean(gracePeriod, 10, 'completed'),
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
]);
}
/**
* Cancel all pending jobs for a specific project
* This should be called when a project is disabled
*/
public static async cancelAllProjectJobs(projectId: string): Promise<void> {
signale.info(`[QUEUE] Cancelling all pending jobs for project ${projectId}`);
// Cancel all scheduled campaigns for this project
const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']);
for (const job of scheduledCampaigns) {
// We need to check if the campaign belongs to this project
// by looking up the campaign in the database
const campaign = await prisma.campaign.findUnique({
where: {id: job.data.campaignId},
select: {projectId: true},
});
if (campaign?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed scheduled campaign job ${job.id}`);
}
}
// Cancel all pending emails for this project
const pendingEmails = await emailQueue.getJobs(['waiting', 'delayed']);
for (const job of pendingEmails) {
const email = await prisma.email.findUnique({
where: {id: job.data.emailId},
select: {projectId: true},
});
if (email?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed email job ${job.id}`);
}
}
// Cancel all pending campaign batches for this project
const campaignBatches = await campaignQueue.getJobs(['waiting', 'delayed']);
for (const job of campaignBatches) {
const campaign = await prisma.campaign.findUnique({
where: {id: job.data.campaignId},
select: {projectId: true},
});
if (campaign?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed campaign batch job ${job.id}`);
}
}
// Cancel all pending workflow steps for this project
const workflowSteps = await workflowQueue.getJobs(['waiting', 'delayed']);
for (const job of workflowSteps) {
const execution = await prisma.workflowExecution.findUnique({
where: {id: job.data.executionId},
select: {workflow: {select: {projectId: true}}},
});
if (execution?.workflow.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed workflow step job ${job.id}`);
}
}
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
}
/**
* Close all queue connections
*/
public static async closeAll(): Promise<void> {
await Promise.all([
emailQueue.close(),
campaignQueue.close(),
workflowQueue.close(),
scheduledQueue.close(),
importQueue.close(),
segmentCountQueue.close(),
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
]);
}
}
+165
View File
@@ -0,0 +1,165 @@
import {
S3Client,
PutObjectCommand,
CreateBucketCommand,
HeadBucketCommand,
PutBucketPolicyCommand,
} from '@aws-sdk/client-s3';
import crypto from 'crypto';
import signale from 'signale';
import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET,
S3_BUCKET,
S3_PUBLIC_URL,
S3_FORCE_PATH_STYLE,
S3_ENABLED,
} from '../app/constants.js';
/**
* S3-compatible storage client for Minio
*/
let s3Client: S3Client | null = null;
if (S3_ENABLED) {
s3Client = new S3Client({
region: 'us-east-1', // Minio doesn't use regions, but AWS SDK requires this parameter
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_ACCESS_KEY_SECRET,
},
forcePathStyle: S3_FORCE_PATH_STYLE, // Required for Minio (uses path-style URLs)
});
}
/**
* Initialize the S3 bucket if it doesn't exist
*/
export async function initializeBucket(): Promise<void> {
if (!s3Client) {
throw new Error('S3 is not enabled');
}
let bucketExists = true;
try {
// Check if bucket exists
await s3Client.send(
new HeadBucketCommand({
Bucket: S3_BUCKET,
}),
);
} catch (error: unknown) {
const isNotFoundError =
error &&
typeof error === 'object' &&
(('name' in error && error.name === 'NotFound') ||
('$metadata' in error &&
error.$metadata &&
typeof error.$metadata === 'object' &&
'httpStatusCode' in error.$metadata &&
error.$metadata.httpStatusCode === 404));
if (isNotFoundError) {
bucketExists = false;
// Bucket doesn't exist, create it
try {
await s3Client.send(
new CreateBucketCommand({
Bucket: S3_BUCKET,
}),
);
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
} catch (createError) {
signale.error('[S3] Failed to create bucket:', createError);
throw createError;
}
} else {
signale.error('[S3] Failed to check bucket:', error);
throw error;
}
}
// Set public read policy for the bucket (both for new and existing buckets)
try {
const bucketPolicy = {
Version: '2012-10-17',
Statement: [
{
Sid: 'PublicReadGetObject',
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${S3_BUCKET}/*`],
},
],
};
await s3Client.send(
new PutBucketPolicyCommand({
Bucket: S3_BUCKET,
Policy: JSON.stringify(bucketPolicy),
}),
);
if (!bucketExists) {
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
}
} catch (policyError) {
signale.error('[S3] Failed to set bucket policy:', policyError);
// Don't throw - bucket was created but policy failed
}
}
interface UploadFileParams {
file: Buffer;
filename: string;
contentType: string;
projectId: string;
}
interface UploadFileResult {
url: string;
key: string;
}
/**
* Upload a file to S3/Minio
*/
export async function uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
if (!s3Client) {
throw new Error('S3 is not enabled');
}
const {file, filename, contentType, projectId} = params;
// Generate a unique key for the file
const timestamp = Date.now();
const randomString = crypto.randomBytes(8).toString('hex');
const extension = filename.split('.').pop();
const key = `${projectId}/${timestamp}-${randomString}.${extension}`;
// Upload to S3/Minio
await s3Client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: file,
ContentType: contentType,
}),
);
// Construct public URL
const url = `${S3_PUBLIC_URL}/${key}`;
return {url, key};
}
/**
* Check if S3 is enabled and configured
*/
export function isS3Enabled(): boolean {
return S3_ENABLED;
}
+282
View File
@@ -0,0 +1,282 @@
import {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_REGION,
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
} from '../app/constants.js';
/**
* AWS SES Client
*/
export const ses = new SES({
apiVersion: '2010-12-01',
region: AWS_SES_REGION,
credentials: {
accessKeyId: AWS_SES_ACCESS_KEY_ID,
secretAccessKey: AWS_SES_SECRET_ACCESS_KEY,
},
});
interface SendRawEmailParams {
from: {
name: string;
email: string;
};
to: string[] | {name?: string; email: string}[];
content: {
subject: string;
html: string;
};
reply?: string;
headers?: Record<string, string> | null;
attachments?:
| {
filename: string;
content: string; // Base64 encoded
contentType: string;
}[]
| null;
tracking?: boolean;
}
/**
* Break long lines to comply with email RFC standards
*/
function breakLongLines(input: string, maxLineLength: number, isBase64 = false): string {
if (isBase64) {
// For base64 content, break at exact intervals without looking for spaces
const result = [];
for (let i = 0; i < input.length; i += maxLineLength) {
result.push(input.substring(i, i + maxLineLength));
}
return result.join('\n');
} else {
// For text content, break at spaces when possible
const lines = input.split('\n');
const result = [];
for (let line of lines) {
while (line.length > maxLineLength) {
let pos = maxLineLength;
while (pos > 0 && line[pos] !== ' ') {
pos--;
}
if (pos === 0) {
pos = maxLineLength;
}
result.push(line.substring(0, pos));
line = line.substring(pos).trim();
}
result.push(line);
}
return result.join('\n');
}
}
/**
* Send a raw email via AWS SES with full MIME formatting
*/
export async function sendRawEmail({
from,
to,
content,
reply,
headers,
attachments,
tracking = true,
}: SendRawEmailParams): Promise<{messageId: string}> {
// Check if the body contains an unsubscribe link
const regex = /unsubscribe\/([a-f\d-]+)"/;
const containsUnsubscribeLink = regex.exec(content.html);
let unsubscribeHeader = '';
if (containsUnsubscribeLink?.[1]) {
const unsubscribeId = containsUnsubscribeLink[1];
unsubscribeHeader = `List-Unsubscribe: <${DASHBOARD_URI}/unsubscribe/${unsubscribeId}>`;
}
// Generate unique boundaries for multipart messages
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
// Format To header with names if provided
const toHeader = to
.map(recipient => {
if (typeof recipient === 'string') {
return recipient;
} else {
return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
}
})
.join(', ');
// Extract just email addresses for Destinations (SES requirement)
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
// Build raw MIME message
const rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
${
mixedBoundary
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
: `Content-Type: multipart/alternative; boundary="${boundary}"`
}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
}--${boundary}
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)}
--${boundary}--
${
attachments && attachments.length > 0
? '\n' +
attachments
.map(
attachment => `--${mixedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`,
)
.join('\n')
: ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
const configurationSetName =
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
// Send via SES
const response = await ses.sendRawEmail({
Destinations: destinations,
ConfigurationSetName: configurationSetName,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
Source: `${from.name} <${from.email}>`,
});
if (!response.MessageId) {
throw new Error('Could not send email');
}
return {messageId: response.MessageId};
}
/**
* Get verification attributes for multiple domain identities
*/
export const getIdentities = async (domains: string[]): Promise<{domain: string; status: string}[]> => {
const res = await ses.getIdentityVerificationAttributes({
Identities: domains,
});
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
return parsedResult.map(obj => {
return {domain: obj[0], status: obj[1].VerificationStatus ?? 'NotStarted'};
});
};
/**
* Verify a domain and get DKIM tokens for DNS configuration
*/
export const verifyDomain = async (domain: string): Promise<string[]> => {
// Verify DKIM for the domain
const DKIM = await ses.verifyDomainDkim({Domain: domain});
// Set custom MAIL FROM domain (plunk.yourdomain.com)
await ses.setIdentityMailFromDomain({
Identity: domain,
MailFromDomain: `plunk.${domain}`,
});
return DKIM.DkimTokens ?? [];
};
/**
* Get DKIM verification attributes for a domain
*/
export const getDomainVerificationAttributes = async (domain: string) => {
const attributes = await ses.getIdentityDkimAttributes({
Identities: [domain],
});
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
if (parsedAttributes.length === 0) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
const firstAttribute = parsedAttributes[0];
if (!firstAttribute) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
return {
domain: firstAttribute[0],
tokens: firstAttribute[1].DkimTokens ?? [],
status: firstAttribute[1].DkimVerificationStatus ?? 'NotStarted',
};
};
/**
* Disable bounce/complaint forwarding for a verified domain
*/
export const disableFeedbackForwarding = async (domain: string): Promise<void> => {
await ses.setIdentityFeedbackForwardingEnabled({
Identity: domain,
ForwardingEnabled: false,
});
};
/**
* Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails
*/
export const getSendingQuota = async (): Promise<{
maxSendRate: number;
max24HourSend: number;
sentLast24Hours: number;
} | null> => {
try {
const quota = await ses.getSendQuota({});
return {
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
sentLast24Hours: quota.SentLast24Hours ?? 0,
};
} catch (error) {
signale.error('[SES] Failed to fetch sending quota:', error);
return null;
}
};
+435
View File
@@ -0,0 +1,435 @@
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
* These limits protect AWS SES reputation and prevent account suspension
*/
const SECURITY_THRESHOLDS = {
// Minimum emails required before enforcing limits (prevents false positives)
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
BOUNCE_7DAY_WARNING: 3,
BOUNCE_7DAY_CRITICAL: 8,
BOUNCE_ALLTIME_WARNING: 2,
BOUNCE_ALLTIME_CRITICAL: 5,
// Complaint rate thresholds (spam reports)
COMPLAINT_7DAY_WARNING: 0.05,
COMPLAINT_7DAY_CRITICAL: 0.1,
COMPLAINT_ALLTIME_WARNING: 0.02,
COMPLAINT_ALLTIME_CRITICAL: 0.08,
} as const;
interface RateData {
total: number;
bounces: number;
complaints: number;
bounceRate: number;
complaintRate: number;
}
interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
sevenDay: RateData;
allTime: RateData;
violations: string[];
warnings: string[];
}
export class SecurityService {
private static readonly CACHE_TTL = 300; // 5 minutes
/**
* Get security status for a project (with caching)
*/
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
try {
// Try to get from cache first
const cacheKey = Keys.Security.rates(projectId);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Calculate fresh data
const status = await this.calculateSecurityStatus(projectId);
// Cache the result
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(status));
return status;
} catch (error) {
signale.error('[SECURITY] Failed to get security status:', error);
// Return safe defaults on error
return {
projectId,
isHealthy: true,
shouldDisable: false,
sevenDay: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
allTime: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
violations: [],
warnings: [],
};
}
}
/**
* Check security status and auto-disable project if thresholds are exceeded
* This should be called after bounce/complaint events are processed
*/
public static async checkAndEnforceSecurityLimits(projectId: string): Promise<void> {
try {
// Invalidate cache to get fresh data
await this.invalidateCache(projectId);
// Get current security status
const status = await this.getSecurityStatus(projectId);
// If project should be disabled, disable it (only if auto-disable is enabled)
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
await this.disableProject(projectId, status);
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
// Log critical violations but don't auto-disable (self-hosted mode)
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
}
} else if (status.warnings.length > 0) {
// Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about security warning
await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings);
}
}
} catch (error) {
// Log error but don't throw - we don't want security checks to break the webhook
signale.error(`[SECURITY] Failed to check security limits for project ${projectId}:`, error);
}
}
/**
* Invalidate cached security data for a project
* Should be called after bounce/complaint events
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const cacheKey = Keys.Security.rates(projectId);
await redis.del(cacheKey);
} catch (error) {
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
}
}
/**
* Check if a user is a member of any disabled project
* Users with disabled projects cannot create new projects
*/
public static async userHasDisabledProject(userId: string): Promise<{
hasDisabledProject: boolean;
disabledProjectNames: string[];
}> {
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),
};
}
/**
* Check if a specific project is disabled
*/
public static async isProjectDisabled(projectId: string): Promise<boolean> {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
return project?.disabled ?? false;
}
/**
* Get a project's security metrics (for admin/dashboard display)
*/
public static async getProjectSecurityMetrics(projectId: string): Promise<{
status: SecurityStatus;
thresholds: typeof SECURITY_THRESHOLDS;
isDisabled: boolean;
}> {
const [status, project] = await Promise.all([
this.getSecurityStatus(projectId),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
]);
return {
status,
thresholds: SECURITY_THRESHOLDS,
isDisabled: project?.disabled ?? false,
};
}
/**
* Calculate bounce and complaint rates for a project
*/
private static async calculateRates(projectId: string, startDate?: Date): Promise<RateData> {
const where = {
projectId,
...(startDate && {
createdAt: {
gte: startDate,
},
}),
};
// Get counts in parallel for performance
const [total, bounces, complaints] = await Promise.all([
prisma.email.count({where}),
prisma.email.count({
where: {
...where,
bouncedAt: {not: null},
},
}),
prisma.email.count({
where: {
...where,
complainedAt: {not: null},
},
}),
]);
const bounceRate = total > 0 ? (bounces / total) * 100 : 0;
const complaintRate = total > 0 ? (complaints / total) * 100 : 0;
return {
total,
bounces,
complaints,
bounceRate,
complaintRate,
};
}
/**
* Calculate security status without caching
*/
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get 7-day and all-time rates in parallel
const [sevenDay, allTime] = await Promise.all([
this.calculateRates(projectId, sevenDaysAgo),
this.calculateRates(projectId),
]);
const violations: string[] = [];
const warnings: string[] = [];
// Only enforce if minimum emails threshold is met
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
if (hasMinimumVolume) {
// Check 7-day bounce rate
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
);
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
warnings.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
);
}
// Check all-time bounce rate
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
);
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
warnings.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
);
}
// 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}%)`,
);
}
}
return {
projectId,
isHealthy: violations.length === 0,
shouldDisable: violations.length > 0,
sevenDay,
allTime,
violations,
warnings,
};
}
/**
* Disable a project due to security violations
*/
private static async disableProject(projectId: string, status: SecurityStatus): Promise<void> {
try {
// Check if already disabled to avoid duplicate logs
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {id: true, disabled: true, name: true},
});
if (!project) {
signale.error(`[SECURITY] Project ${projectId} not found`);
return;
}
if (project.disabled) {
// Already disabled, just log the current violations
signale.warn(
`[SECURITY] Project ${projectId} (${project.name}) already disabled. Current violations:`,
status.violations,
);
return;
}
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true},
});
// Log critical security event
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Cancel all pending jobs for this project
try {
await QueueService.cancelAllProjectJobs(projectId);
signale.info(`[SECURITY] Cancelled all pending jobs for project ${projectId}`);
} catch (error) {
signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error);
}
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
// Send email notification to project members
try {
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: status.violations,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
);
}
} catch (emailError) {
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
}
}
+994
View File
@@ -0,0 +1,994 @@
import {type Contact, Prisma, type Segment} from '@plunk/db';
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* Convert segment name to a URL-safe slug for event names
* Example: "VIP Customers" -> "vip-customers"
*/
function slugifySegmentName(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
export class SegmentService {
/**
* Get all segments for a project
* Uses cached member counts for performance - counts are updated via background job
*/
public static async list(projectId: string): Promise<Segment[]> {
return prisma.segment.findMany({
where: {projectId},
orderBy: {createdAt: 'desc'},
});
}
/**
* Get a single segment by ID
* Uses cached member count for performance - counts are updated via background job
*/
public static async get(projectId: string, segmentId: string): Promise<Segment> {
const segment = await prisma.segment.findFirst({
where: {
id: segmentId,
projectId,
},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
return segment;
}
/**
* Get contacts that match a segment's condition
*/
public static async getContacts(
projectId: string,
segmentId: string,
page = 1,
pageSize = 20,
): Promise<PaginatedContacts> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize;
const [contacts, total] = await Promise.all([
prisma.contact.findMany({
where,
skip,
take: pageSize,
orderBy: {createdAt: 'desc'},
}),
prisma.contact.count({where}),
]);
return {
contacts,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Create a new segment
*/
public static async create(
projectId: string,
data: {
name: string;
description?: string;
condition: FilterCondition;
trackMembership?: boolean;
},
): Promise<Segment> {
// Validate condition
this.validateCondition(data.condition);
// Compute initial member count
const where = this.buildWhereClause(projectId, data.condition);
const memberCount = await prisma.contact.count({where});
const segment = await prisma.segment.create({
data: {
projectId,
name: data.name,
description: data.description,
condition: data.condition as unknown as Prisma.InputJsonValue,
trackMembership: data.trackMembership ?? false,
memberCount,
},
include: {
project: {
select: {name: true},
},
},
});
// Notify about segment creation
await NtfyService.notifySegmentCreated(segment.name, segment.project.name, projectId);
return segment;
}
/**
* Update a segment
*/
public static async update(
projectId: string,
segmentId: string,
data: {
name?: string;
description?: string;
condition?: FilterCondition;
trackMembership?: boolean;
},
): Promise<Segment> {
// First verify segment exists and belongs to project
await this.get(projectId, segmentId);
// Validate condition if provided
if (data.condition) {
this.validateCondition(data.condition);
}
const updateData: Prisma.SegmentUpdateInput = {};
if (data.name !== undefined) {
updateData.name = data.name;
}
if (data.description !== undefined) {
updateData.description = data.description;
}
if (data.condition !== undefined) {
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
// Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition);
updateData.memberCount = await prisma.contact.count({where});
}
if (data.trackMembership !== undefined) {
updateData.trackMembership = data.trackMembership;
}
return prisma.segment.update({
where: {id: segmentId},
data: updateData,
});
}
/**
* Delete a segment
*/
public static async delete(projectId: string, segmentId: string): Promise<void> {
// First verify segment exists and belongs to project
const segment = await prisma.segment.findFirst({
where: {
id: segmentId,
projectId,
},
include: {
project: {
select: {name: true},
},
},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
// Check if segment is used in any active campaigns
const campaignsUsingSegment = await prisma.campaign.count({
where: {
segmentId,
status: {
in: ['DRAFT', 'SCHEDULED', 'SENDING'],
},
},
});
if (campaignsUsingSegment > 0) {
throw new HttpException(
409,
`Cannot delete segment: it is currently used in ${campaignsUsingSegment} active campaign(s). Remove it from campaigns first or wait for them to complete.`,
);
}
await prisma.segment.delete({
where: {id: segmentId},
});
// Notify about segment deletion
await NtfyService.notifySegmentDeleted(segment.name, segment.project.name, projectId);
}
/**
* Refresh segment member count (for background jobs or manual refresh)
* This is now the primary way to update segment counts
*/
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where});
await prisma.segment.update({
where: {id: segmentId},
data: {memberCount},
});
return memberCount;
}
/**
* Refresh member counts for all segments in a project
* Should be called by a background job periodically
*/
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, condition: true},
});
// Process in batches to avoid overwhelming the database
const BATCH_SIZE = 5;
for (let i = 0; i < segments.length; i += BATCH_SIZE) {
const batch = segments.slice(i, i + BATCH_SIZE);
await Promise.all(
batch.map(async segment => {
try {
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where});
await prisma.segment.update({
where: {id: segment.id},
data: {memberCount},
});
} catch (error) {
signale.error(`Failed to update count for segment ${segment.id}:`, error);
}
}),
);
}
}
/**
* Compute or recompute segment membership for all contacts
* Now uses cursor-based pagination for memory efficiency with large contact lists
*/
public static async computeMembership(
projectId: string,
segmentId: string,
): Promise<{added: number; removed: number; total: number}> {
const segment = await this.get(projectId, segmentId);
if (!segment.trackMembership) {
throw new HttpException(400, 'Segment does not have membership tracking enabled');
}
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues
const BATCH_SIZE = 1000;
const matchingContactIds = new Set<string>();
let cursor: string | undefined = undefined;
let hasMore = true;
while (hasMore) {
const contacts: {id: string}[] = await prisma.contact.findMany({
where,
select: {id: true},
take: BATCH_SIZE,
skip: cursor ? 1 : 0,
cursor: cursor ? {id: cursor} : undefined,
orderBy: {id: 'asc'},
});
contacts.forEach((c: {id: string}) => matchingContactIds.add(c.id));
if (contacts.length < BATCH_SIZE) {
hasMore = false;
} else {
const lastContact = contacts[contacts.length - 1];
cursor = lastContact?.id;
}
}
// Get current active memberships using cursor pagination
const currentMemberIds = new Set<string>();
cursor = undefined;
hasMore = true;
while (hasMore) {
const memberships: {contactId: string}[] = await prisma.segmentMembership.findMany({
where: {
segmentId,
exitedAt: null,
},
select: {contactId: true},
take: BATCH_SIZE,
skip: cursor ? 1 : 0,
cursor: cursor ? {contactId_segmentId: {contactId: cursor, segmentId}} : undefined,
orderBy: {contactId: 'asc'},
});
memberships.forEach((m: {contactId: string}) => currentMemberIds.add(m.contactId));
if (memberships.length < BATCH_SIZE) {
hasMore = false;
} else {
const lastMembership = memberships[memberships.length - 1];
cursor = lastMembership?.contactId;
}
}
// Calculate changes
const toAdd = Array.from(matchingContactIds).filter(id => !currentMemberIds.has(id));
const toRemove = Array.from(currentMemberIds).filter(id => !matchingContactIds.has(id));
// Process additions in batches
const ADD_BATCH_SIZE = 500;
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
await prisma.segmentMembership.createMany({
data: batch.map(contactId => ({
segmentId,
contactId,
enteredAt: new Date(),
})),
skipDuplicates: true,
});
// Create segment-specific entry events for each contact in the batch
for (const contactId of batch) {
try {
// Create a human-readable event name using slugified segment name
const segmentSlug = slugifySegmentName(segment.name);
const eventName = `segment.${segmentSlug}.entry`;
await EventService.trackEvent(projectId, eventName, contactId, undefined, {
segmentId: segment.id,
segmentName: segment.name,
});
} catch (error) {
signale.error(`[SEGMENT] Failed to track segment entry event for contact ${contactId}:`, error);
}
}
}
const REMOVE_BATCH_SIZE = 500;
for (let i = 0; i < toRemove.length; i += REMOVE_BATCH_SIZE) {
const batch = toRemove.slice(i, i + REMOVE_BATCH_SIZE);
await prisma.segmentMembership.updateMany({
where: {
segmentId,
contactId: {in: batch},
exitedAt: null,
},
data: {
exitedAt: new Date(),
},
});
// Create segment-specific exit events for each contact in the batch
for (const contactId of batch) {
try {
// Create a human-readable event name using slugified segment name
const segmentSlug = slugifySegmentName(segment.name);
const eventName = `segment.${segmentSlug}.exit`;
await EventService.trackEvent(projectId, eventName, contactId, undefined, {
segmentId: segment.id,
segmentName: segment.name,
});
} catch (error) {
signale.error(`[SEGMENT] Failed to track segment exit event for contact ${contactId}:`, error);
}
}
}
// Update member count on segment
await prisma.segment.update({
where: {id: segmentId},
data: {memberCount: matchingContactIds.size},
});
signale.info(
`[SEGMENT] Computed membership for segment ${segmentId}: added ${toAdd.length}, removed ${toRemove.length}, total ${matchingContactIds.size}`,
);
return {
added: toAdd.length,
removed: toRemove.length,
total: matchingContactIds.size,
};
}
/**
* Build a single filter condition
*/
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
const {field, operator, value, unit} = filter;
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
if (field.startsWith('event.')) {
const eventName = field.substring(6); // Remove "event." prefix
return this.buildEventCondition(eventName, operator, value, unit);
}
// Handle email activity filters (e.g., "email.opened", "email.clicked")
if (field.startsWith('email.')) {
const activity = field.substring(6); // Remove "email." prefix
return this.buildEmailActivityCondition(activity, operator, value, unit);
}
// Handle JSON field paths (e.g., "data.plan")
if (field.startsWith('data.')) {
const jsonPath = field.substring(5); // Remove "data." prefix
return this.buildJsonFieldCondition(jsonPath, operator, value, unit);
}
// Handle regular fields
switch (field) {
case 'email':
return this.buildStringFieldCondition('email', operator, value);
case 'subscribed':
return this.buildBooleanFieldCondition('subscribed', operator, value);
case 'createdAt':
case 'updatedAt':
return this.buildDateFieldCondition(field, operator, value, unit);
default:
throw new HttpException(400, `Unsupported filter field: ${field}`);
}
}
/**
* Validate segment condition (recursive)
*/
public static validateCondition(condition: FilterCondition): void {
if (!condition || typeof condition !== 'object') {
throw new HttpException(400, 'Condition must be an object');
}
if (!condition.logic || !['AND', 'OR'].includes(condition.logic)) {
throw new HttpException(400, 'Condition logic must be either "AND" or "OR"');
}
if (!Array.isArray(condition.groups) || condition.groups.length === 0) {
throw new HttpException(400, 'Condition must have at least one group');
}
for (const group of condition.groups) {
this.validateGroup(group);
}
}
/**
* Validate filter group (recursive)
*/
private static validateGroup(group: FilterGroup): void {
if (!group || typeof group !== 'object') {
throw new HttpException(400, 'Group must be an object');
}
if (!Array.isArray(group.filters)) {
throw new HttpException(400, 'Group filters must be an array');
}
// Groups can have filters, nested conditions, or both
const hasFilters = group.filters.length > 0;
const hasConditions = group.conditions !== undefined;
if (!hasFilters && !hasConditions) {
throw new HttpException(400, 'Group must have at least one filter or nested condition');
}
// Validate all filters in the group
for (const filter of group.filters) {
this.validateFilter(filter);
}
// Recursively validate nested conditions
if (group.conditions) {
this.validateCondition(group.conditions);
}
}
/**
* Validate individual filter
*/
private static validateFilter(filter: SegmentFilter): void {
if (!filter.field) {
throw new HttpException(400, 'Filter field is required');
}
if (!filter.operator) {
throw new HttpException(400, 'Filter operator is required');
}
const validOperators = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
'triggered',
'triggeredWithin',
'notTriggered',
];
if (!validOperators.includes(filter.operator)) {
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
}
// Validate that operators that need a value have one
const operatorsNeedingValue = [
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'within',
'triggeredWithin',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
throw new HttpException(400, `Operator "${filter.operator}" requires a value`);
}
// Validate unit for time-based operators
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
}
}
/**
* Build Prisma where clause from filter condition (entry point)
*/
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
return {
projectId,
...this.buildConditionClause(condition),
};
}
/**
* 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)
*/
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
const clauses: Prisma.ContactWhereInput[] = [];
// Add filter conditions from this group
if (group.filters.length > 0) {
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
}
// Add nested condition if present
if (group.conditions) {
clauses.push(this.buildConditionClause(group.conditions));
}
// All conditions within a group are combined with AND
if (clauses.length === 0) {
return {}; // Empty group returns empty where clause
}
if (clauses.length === 1) {
return clauses[0]!; // Safe to use non-null assertion since we checked length
}
return {AND: clauses};
}
/**
* Build condition for JSON fields (stored in contact.data)
*/
private static buildJsonFieldCondition(
jsonPath: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
const path = jsonPath.split('.');
switch (operator) {
case 'equals':
// For date strings, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [
{data: {path, gte: startOfDay as Prisma.InputJsonValue}},
{data: {path, lt: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {data: {path, equals: value as Prisma.InputJsonValue}};
case 'notEquals':
// For date strings, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [
{data: {path, lt: startOfDay as Prisma.InputJsonValue}},
{data: {path, gte: startOfNextDay as Prisma.InputJsonValue}},
],
};
}
return {NOT: {data: {path, equals: value as Prisma.InputJsonValue}}};
case 'contains':
return {data: {path, string_contains: String(value)}};
case 'notContains':
return {NOT: {data: {path, string_contains: String(value)}}};
case 'greaterThan':
return {data: {path, gt: value as Prisma.InputJsonValue}};
case 'lessThan':
return {data: {path, lt: value as Prisma.InputJsonValue}};
case 'greaterThanOrEqual':
return {data: {path, gte: value as Prisma.InputJsonValue}};
case 'lessThanOrEqual':
return {data: {path, lte: value as Prisma.InputJsonValue}};
case 'exists':
// Exists = key present with a non-null JSON value (neither DbNull nor JsonNull)
return {
NOT: {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
},
};
case 'notExists':
// Not exists = key is null / missing, represented as either DbNull or JsonNull
return {
OR: [{data: {path, equals: Prisma.DbNull}}, {data: {path, equals: Prisma.JsonNull}}],
};
case 'within': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
throw new HttpException(400, 'Unit is required for "within" operator');
}
// Calculate the "since" date (X time units ago from now)
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
// Use ISO string for lexicographic comparison in JSON
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
}
default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
}
}
/**
* Build condition for string fields
*/
private static buildStringFieldCondition(field: 'email', operator: string, value: unknown): Prisma.ContactWhereInput {
switch (operator) {
case 'equals':
return {[field]: {equals: String(value), mode: 'insensitive'}};
case 'notEquals':
return {NOT: {[field]: {equals: String(value), mode: 'insensitive'}}};
case 'contains':
return {[field]: {contains: String(value), mode: 'insensitive'}};
case 'notContains':
return {NOT: {[field]: {contains: String(value), mode: 'insensitive'}}};
default:
throw new HttpException(400, `Unsupported operator for string field: ${operator}`);
}
}
/**
* Build condition for boolean fields
*/
private static buildBooleanFieldCondition(
field: 'subscribed',
operator: string,
value: unknown,
): Prisma.ContactWhereInput {
switch (operator) {
case 'equals':
return {[field]: value === true};
case 'notEquals':
return {[field]: value !== true};
default:
throw new HttpException(400, `Unsupported operator for boolean field: ${operator}`);
}
}
/**
* Build condition for date fields
*/
private static buildDateFieldCondition(
field: 'createdAt' | 'updatedAt',
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'equals': {
// For date fields, compare only the date portion (ignore time)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
AND: [{[field]: {gte: new Date(startOfDay)}}, {[field]: {lt: new Date(startOfNextDay)}}],
};
}
// Exact timestamp match if not a date string
return {[field]: new Date(value as string | number | Date)};
}
case 'notEquals': {
// For date fields, exclude the entire day (not just exact timestamp)
if (this.isDateString(value)) {
const {startOfDay, startOfNextDay} = this.getDateRange(String(value));
return {
OR: [{[field]: {lt: new Date(startOfDay)}}, {[field]: {gte: new Date(startOfNextDay)}}],
};
}
// Exclude exact timestamp if not a date string
return {NOT: {[field]: new Date(value as string | number | Date)}};
}
case 'greaterThan':
return {[field]: {gt: new Date(value as string | number | Date)}};
case 'lessThan':
return {[field]: {lt: new Date(value as string | number | Date)}};
case 'greaterThanOrEqual':
return {[field]: {gte: new Date(value as string | number | Date)}};
case 'lessThanOrEqual':
return {[field]: {lte: new Date(value as string | number | Date)}};
case 'within': {
// "within X days/hours/minutes" means in the past X time units
if (!unit) {
throw new HttpException(400, 'Unit is required for "within" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {[field]: {gte: since}};
}
default:
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
}
}
/**
* Convert time value and unit to milliseconds
*/
private static getMilliseconds(value: number, unit: 'days' | 'hours' | 'minutes'): number {
switch (unit) {
case 'days':
return value * 24 * 60 * 60 * 1000;
case 'hours':
return value * 60 * 60 * 1000;
case 'minutes':
return value * 60 * 1000;
default:
throw new HttpException(400, `Unsupported time unit: ${unit}`);
}
}
/**
* Check if a value is a date string in YYYY-MM-DD format
*/
private static isDateString(value: unknown): boolean {
if (typeof value !== 'string') return false;
// Match YYYY-MM-DD format (with optional time component)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T|$)/;
if (!dateRegex.test(value)) return false;
// Verify it's a valid date
const date = new Date(value);
return !isNaN(date.getTime());
}
/**
* Get date range for a date string (start of day to start of next day in UTC)
* @param value - Date string in YYYY-MM-DD format
* @returns Object with startOfDay and startOfNextDay as ISO strings
*/
private static getDateRange(value: string): {startOfDay: string; startOfNextDay: string} {
// Extract just the date part (YYYY-MM-DD)
const dateStr = value.split('T')[0];
const startOfDay = `${dateStr}T00:00:00.000Z`;
if (!dateStr) {
throw new HttpException(400, `Invalid date string: ${value}`);
}
// Calculate start of next day
const nextDay = new Date(dateStr);
nextDay.setUTCDate(nextDay.getUTCDate() + 1);
const startOfNextDay = nextDay.toISOString().split('T')[0] + 'T00:00:00.000Z';
return {startOfDay, startOfNextDay};
}
/**
* Build condition for event-based filters
* Uses Prisma relations to efficiently query contacts who triggered specific events
*/
private static buildEventCondition(
eventName: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
switch (operator) {
case 'triggered':
// Contact has triggered this event at any time
return {
events: {
some: {
name: eventName,
},
},
};
case 'triggeredWithin': {
// Contact has triggered this event within the specified timeframe
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
events: {
some: {
name: eventName,
createdAt: {
gte: since,
},
},
},
};
}
case 'notTriggered':
// Contact has never triggered this event
return {
events: {
none: {
name: eventName,
},
},
};
default:
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
}
}
/**
* Build condition for email activity filters
* Uses Prisma relations to efficiently query contacts based on email engagement
*/
private static buildEmailActivityCondition(
activity: string,
operator: string,
value: unknown,
unit?: 'days' | 'hours' | 'minutes',
): Prisma.ContactWhereInput {
// Map activity names to Email model fields
const fieldMap: Record<string, string> = {
opened: 'openedAt',
clicked: 'clickedAt',
bounced: 'bouncedAt',
complained: 'complainedAt',
sent: 'sentAt',
delivered: 'deliveredAt',
};
const field = fieldMap[activity];
if (!field) {
throw new HttpException(400, `Unsupported email activity: ${activity}`);
}
switch (operator) {
case 'triggered':
// Contact has this email activity at any time
return {
emails: {
some: {
[field]: {
not: null,
},
},
},
};
case 'triggeredWithin': {
// Contact has this email activity within the specified timeframe
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredWithin" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const since = new Date(now.getTime() - milliseconds);
return {
emails: {
some: {
[field]: {
gte: since,
},
},
},
};
}
case 'notTriggered':
// Contact has never had this email activity
return {
emails: {
none: {
[field]: {
not: null,
},
},
},
};
default:
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
}
}
}
+218
View File
@@ -0,0 +1,218 @@
import type {Template} from '@plunk/db';
import {Prisma} from '@plunk/db';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
export interface PaginatedTemplates {
templates: Template[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export class TemplateService {
/**
* Get all templates for a project with pagination
*/
public static async list(
projectId: string,
page = 1,
pageSize = 20,
search?: string,
type?: Template['type'],
): Promise<PaginatedTemplates> {
const skip = (page - 1) * pageSize;
const where: Prisma.TemplateWhereInput = {
projectId,
...(type ? {type} : {}),
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{description: {contains: search, mode: 'insensitive' as const}},
{subject: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
};
const [templates, total] = await Promise.all([
prisma.template.findMany({
where,
skip,
take: pageSize,
orderBy: {createdAt: 'desc'},
}),
prisma.template.count({where}),
]);
return {
templates,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Get a single template by ID
*/
public static async get(projectId: string, templateId: string): Promise<Template> {
const template = await prisma.template.findFirst({
where: {
id: templateId,
projectId,
},
});
if (!template) {
throw new HttpException(404, 'Template not found');
}
return template;
}
/**
* Create a new template
*/
public static async create(
projectId: string,
data: {
name: string;
description?: string;
subject: string;
body: string;
from: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
return prisma.template.create({
data: {
projectId,
name: data.name,
description: data.description,
subject: data.subject,
body: data.body,
from: data.from,
fromName: data.fromName,
replyTo: data.replyTo,
type: data.type ?? 'MARKETING',
},
});
}
/**
* Update a template
*/
public static async update(
projectId: string,
templateId: string,
data: {
name?: string;
description?: string;
subject?: string;
body?: string;
from?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
const updateData = {
...buildEmailFieldsUpdate(data),
...(data.type !== undefined ? {type: data.type} : {}),
} as Prisma.TemplateUpdateInput;
return prisma.template.update({
where: {id: templateId},
data: updateData,
});
}
/**
* Delete a template
*/
public static async delete(projectId: string, templateId: string): Promise<void> {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
// Check if template is used in any workflows
const workflowSteps = await prisma.workflowStep.count({
where: {
templateId,
workflow: {projectId},
},
});
if (workflowSteps > 0) {
throw new HttpException(
409,
'Cannot delete template: it is currently used in workflow steps. Remove it from workflows first.',
);
}
await prisma.template.delete({
where: {id: templateId},
});
}
/**
* Duplicate a template
*/
public static async duplicate(projectId: string, templateId: string): Promise<Template> {
const template = await this.get(projectId, templateId);
// Create a new template with the same data
return prisma.template.create({
data: {
projectId,
name: `${template.name} (Copy)`,
description: template.description,
subject: template.subject,
body: template.body,
from: template.from,
fromName: template.fromName,
replyTo: template.replyTo,
type: template.type,
},
});
}
/**
* Get template usage statistics
*/
public static async getUsage(projectId: string, templateId: string) {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
const [workflowStepsCount, emailsCount] = await Promise.all([
prisma.workflowStep.count({
where: {
templateId,
workflow: {projectId},
},
}),
prisma.email.count({
where: {
templateId,
projectId,
},
}),
]);
return {
workflowSteps: workflowStepsCount,
emailsSent: emailsCount,
};
}
}
+98
View File
@@ -0,0 +1,98 @@
import dayjs from 'dayjs';
import {API_URI, NODE_ENV} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Extract base domain from URL for cookie sharing across subdomains
* e.g., "http://api.example.com" -> ".example.com"
* e.g., "http://api.localhost" -> ".localhost"
*/
function getCookieDomain(): string | undefined {
if (NODE_ENV === 'development') {
return undefined;
}
try {
const url = new URL(API_URI);
const hostname = url.hostname;
// For localhost or IP addresses, don't set a domain
if (hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return undefined;
}
// Extract base domain (last two parts for most domains, or .localhost)
const parts = hostname.split('.');
if (parts.length >= 2) {
// For *.localhost or *.local, use the full hostname with leading dot
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
return '.localhost';
}
// For other domains, use the last two parts (e.g., .example.com)
return `.${parts.slice(-2).join('.')}`;
}
return undefined;
} catch {
return undefined;
}
}
export class UserService {
public static readonly COOKIE_NAME = 'next_token';
public static async id(id: string) {
return wrapRedis(Keys.User.id(id), async () => {
return prisma.user.findUnique({where: {id}});
});
}
public static async email(email: string) {
if (!email) {
return null;
}
return wrapRedis(Keys.User.email(email), async () => {
return prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
});
}
public static async projects(userId: string) {
const memberships = await prisma.user.findUnique({where: {id: userId}}).memberships({
include: {
project: true,
},
});
return memberships ? memberships.map(({project}) => project) : [];
}
/**
* Generates cookie options
* @param expires An optional expiry for this cookie (useful for a logout)
*/
public static cookieOptions(expires?: Date) {
// Check if using HTTPS from API_URI
const isHttps = NODE_ENV === 'development' ? false : API_URI.startsWith('https://');
return {
httpOnly: true,
expires: expires ?? dayjs().add(7, 'days').toDate(),
secure: isHttps,
sameSite: isHttps ? 'none' : 'lax',
path: '/',
domain: getCookieDomain(),
} as const;
}
}
File diff suppressed because it is too large Load Diff
+990
View File
@@ -0,0 +1,990 @@
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {ContactService} from './ContactService.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
export interface PaginatedWorkflows {
workflows: Workflow[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: WorkflowTransition[];
incomingTransitions: WorkflowTransition[];
})[];
}
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
workflow: Workflow;
contact: {id: string; email: string};
currentStep?: WorkflowStep | null;
stepExecutions: WorkflowStepExecution[];
}
export class WorkflowService {
/**
* Get all workflows for a project with pagination
*/
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
const skip = (page - 1) * pageSize;
const where: Prisma.WorkflowWhereInput = {
projectId,
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{description: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
};
const [workflows, total] = await Promise.all([
prisma.workflow.findMany({
where,
skip,
take: pageSize,
orderBy: {createdAt: 'desc'},
include: {
_count: {
select: {
steps: true,
executions: true,
},
},
},
}),
prisma.workflow.count({where}),
]);
return {
workflows: workflows as Workflow[],
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Get a single workflow by ID with all steps and transitions
*/
public static async get(projectId: string, workflowId: string): Promise<WorkflowWithDetails> {
const workflow = await prisma.workflow.findFirst({
where: {
id: workflowId,
projectId,
},
include: {
steps: {
include: {
template: {
select: {
id: true,
name: true,
},
},
outgoingTransitions: true,
incomingTransitions: true,
},
orderBy: {createdAt: 'asc'},
},
},
});
if (!workflow) {
throw new HttpException(404, 'Workflow not found');
}
return workflow;
}
/**
* Create a new workflow
*/
public static async create(
projectId: string,
data: {
name: string;
description?: string;
eventName: string;
enabled?: boolean;
allowReentry?: boolean;
},
): Promise<Workflow> {
if (!data.eventName?.trim()) {
throw new HttpException(400, 'Event name is required');
}
const workflow = await prisma.$transaction(async tx => {
const newWorkflow = await tx.workflow.create({
data: {
projectId,
name: data.name,
description: data.description,
triggerType: 'EVENT',
triggerConfig: {eventName: data.eventName.trim()},
enabled: data.enabled ?? false,
allowReentry: data.allowReentry ?? false,
},
});
await tx.workflowStep.create({
data: {
workflowId: newWorkflow.id,
type: 'TRIGGER',
name: `Trigger: ${data.eventName.trim()}`,
position: {x: 100, y: 100},
config: {eventName: data.eventName.trim()},
},
});
return newWorkflow;
});
if (workflow.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about workflow creation
await NtfyService.notifyWorkflowCreated(workflow.name, project.name, projectId);
}
return workflow;
}
/**
* Update a workflow
*/
public static async update(
projectId: string,
workflowId: string,
data: {
name?: string;
description?: string;
triggerType?: Workflow['triggerType'];
triggerConfig?: Prisma.JsonValue;
enabled?: boolean;
allowReentry?: boolean;
},
): Promise<Workflow> {
// Verify workflow exists and belongs to project
const workflow = await this.get(projectId, workflowId);
// Check if workflow is enabled and has active executions
if (workflow.enabled) {
const activeExecutions = await this.hasActiveExecutions(workflowId);
if (activeExecutions > 0) {
// Block changes to trigger configuration while executions are running
const hasCriticalChanges = data.triggerType !== undefined || data.triggerConfig !== undefined;
if (hasCriticalChanges) {
throw new HttpException(
409,
`Cannot modify workflow trigger while workflow has ${activeExecutions} active execution(s). ` +
'Please disable the workflow first or wait for executions to complete. ' +
'You can still update name, description, and re-entry settings.',
);
}
}
}
const updateData: Prisma.WorkflowUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
if (data.triggerConfig !== undefined) {
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
}
if (data.enabled !== undefined) updateData.enabled = data.enabled;
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
const updated = await prisma.workflow.update({
where: {id: workflowId},
data: updateData,
include: {
project: {
select: {name: true},
},
},
});
// Invalidate workflow cache if enabled status changed or workflow is enabled
if (data.enabled !== undefined || updated.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
// Send notification if enabled status changed
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
if (data.enabled) {
await NtfyService.notifyWorkflowEnabled(updated.name, updated.project.name, projectId);
} else {
await NtfyService.notifyWorkflowDisabled(updated.name, updated.project.name, projectId);
}
}
return updated;
}
/**
* Delete a workflow
*/
public static async delete(projectId: string, workflowId: string): Promise<void> {
// Verify workflow exists and belongs to project - get with project name
const workflow = await prisma.workflow.findUnique({
where: {id: workflowId, projectId},
include: {
project: {
select: {name: true},
},
},
});
if (!workflow) {
throw new HttpException(404, 'Workflow not found');
}
// Check if workflow has active executions
const activeExecutions = await this.hasActiveExecutions(workflowId);
if (activeExecutions > 0) {
throw new HttpException(
409,
`Cannot delete workflow: it has ${activeExecutions} active execution(s). Please wait for them to complete or cancel them first.`,
);
}
await prisma.workflow.delete({
where: {id: workflowId},
});
// Invalidate workflow cache if workflow was enabled
if (workflow.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
// Send notification about workflow deletion
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
}
/**
* Add a step to a workflow
*/
public static async addStep(
projectId: string,
workflowId: string,
data: {
type: WorkflowStep['type'];
name: string;
position: Prisma.JsonValue;
config: Prisma.JsonValue;
templateId?: string;
autoConnect?: boolean; // If true, automatically connect from the last step
},
): Promise<WorkflowStep> {
// Verify workflow exists and belongs to project
const workflow = await this.get(projectId, workflowId);
// Prevent adding duplicate TRIGGER steps
if (data.type === 'TRIGGER') {
const existingTrigger = workflow.steps.find(step => step.type === 'TRIGGER');
if (existingTrigger) {
throw new HttpException(400, 'Workflow already has a trigger step. Only one trigger is allowed per workflow.');
}
}
// Create the new step
const newStep = await prisma.workflowStep.create({
data: {
workflowId,
type: data.type,
name: data.name,
position: data.position as Prisma.InputJsonValue,
config: data.config as Prisma.InputJsonValue,
templateId: data.templateId,
},
});
// Auto-connect: If enabled (default true), create a transition from the last step to this new step
// This is useful for linear workflows where steps are added sequentially
const shouldAutoConnect = data.autoConnect !== false; // Default to true
if (shouldAutoConnect && workflow.steps.length > 0) {
// Find the last step that doesn't have any outgoing transitions (the "leaf" step)
// This is typically the most recently added step
const stepsWithoutOutgoing = workflow.steps.filter(step => step.outgoingTransitions.length === 0);
if (stepsWithoutOutgoing.length > 0) {
// Connect from the last leaf step to the new step
const lastStep = stepsWithoutOutgoing[stepsWithoutOutgoing.length - 1];
if (lastStep) {
await prisma.workflowTransition.create({
data: {
fromStepId: lastStep.id,
toStepId: newStep.id,
priority: 0,
},
});
}
}
}
return newStep;
}
/**
* Update a workflow step
*/
public static async updateStep(
projectId: string,
workflowId: string,
stepId: string,
data: {
name?: string;
position?: Prisma.JsonValue;
config?: Prisma.JsonValue;
templateId?: string | null;
},
): Promise<WorkflowStep> {
// First verify workflow belongs to project
const workflow = await this.get(projectId, workflowId);
// Then verify step exists and belongs to workflow
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
});
if (step?.workflowId !== workflowId) {
throw new HttpException(404, 'Workflow step not found');
}
// Check if workflow is enabled and has active executions
if (workflow.enabled) {
const activeExecutions = await this.hasActiveExecutions(workflowId);
if (activeExecutions > 0) {
// Only allow safe changes: name and position updates
const hasCriticalChanges = data.config !== undefined || data.templateId !== undefined;
if (hasCriticalChanges) {
throw new HttpException(
409,
`Cannot modify step configuration while workflow has ${activeExecutions} active execution(s). ` +
'Please disable the workflow first or wait for executions to complete. ' +
'You can still update the step name and position.',
);
}
}
}
const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
if (data.templateId !== undefined) {
if (data.templateId === null) {
updateData.template = {disconnect: true};
} else {
updateData.template = {connect: {id: data.templateId}};
}
}
return prisma.workflowStep.update({
where: {id: stepId},
data: updateData,
});
}
/**
* Delete a workflow step
*/
public static async deleteStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
const workflow = await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
include: {
outgoingTransitions: true,
},
});
if (step?.workflowId !== workflowId) {
throw new HttpException(404, 'Workflow step not found');
}
// Prevent deletion of TRIGGER steps
if (step.type === 'TRIGGER') {
throw new HttpException(400, 'Cannot delete the trigger step. Every workflow must have a trigger.');
}
// Check if workflow is enabled and has active executions on this step or downstream
if (workflow.enabled) {
// Check if any active executions are currently on this step
const executionsOnStep = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: stepId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsOnStep > 0) {
throw new HttpException(
409,
`Cannot delete step "${step.name}" while ${executionsOnStep} execution(s) are currently on this step. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
// Also check downstream steps for active executions
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true},
});
// Build adjacency map
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Find all downstream steps
const downstreamSteps = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on downstream steps
const executionsOnDownstream = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsOnDownstream > 0) {
throw new HttpException(
409,
`Cannot delete step "${step.name}" while ${executionsOnDownstream} execution(s) are on downstream steps. ` +
'Deleting this step would orphan those executions. ' +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
// Find all downstream steps that need to be deleted (cascade)
// First, get all steps and transitions for this workflow to build a graph
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true},
});
// Build adjacency map for efficient traversal
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.map(t => t.toStepId),
);
}
// Use BFS to traverse the workflow graph and find all downstream steps
const stepsToDelete = new Set<string>([stepId]);
const queue = [stepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!stepsToDelete.has(nextStepId)) {
stepsToDelete.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Delete all affected steps (Prisma will cascade delete the transitions)
await prisma.workflowStep.deleteMany({
where: {
id: {in: Array.from(stepsToDelete)},
workflowId, // Safety check to ensure we only delete steps from this workflow
},
});
}
/**
* Create a transition between two steps
*/
public static async createTransition(
projectId: string,
workflowId: string,
data: {
fromStepId: string;
toStepId: string;
condition?: Prisma.JsonValue;
priority?: number;
},
): Promise<WorkflowTransition> {
// Verify both steps belong to the workflow
const steps = await prisma.workflowStep.findMany({
where: {
id: {in: [data.fromStepId, data.toStepId]},
workflowId,
workflow: {projectId},
},
});
if (steps.length !== 2) {
throw new HttpException(404, 'One or both steps not found');
}
const fromStep = steps.find(s => s.id === data.fromStepId);
// For CONDITION steps, validate that this branch doesn't already have a transition
if (fromStep?.type === 'CONDITION' && data.condition) {
const conditionObj =
typeof data.condition === 'object' && data.condition !== null
? (data.condition as Record<string, unknown>)
: null;
if (conditionObj && 'branch' in conditionObj) {
// Check if a transition with this branch already exists
const existingTransition = await prisma.workflowTransition.findFirst({
where: {
fromStepId: data.fromStepId,
condition: {
path: ['branch'],
equals: conditionObj.branch as Prisma.InputJsonValue,
},
},
});
if (existingTransition) {
throw new HttpException(
400,
`A transition for the "${conditionObj.branch}" branch already exists from this step`,
);
}
}
}
const newTransition = await prisma.workflowTransition.create({
data: {
fromStepId: data.fromStepId,
toStepId: data.toStepId,
condition: data.condition ?? Prisma.JsonNull,
priority: data.priority ?? 0,
},
});
return newTransition;
}
/**
* Delete a transition
*/
public static async deleteTransition(projectId: string, workflowId: string, transitionId: string): Promise<void> {
// Get workflow to check if it's enabled
const workflow = await this.get(projectId, workflowId);
// Verify transition exists and belongs to workflow
const transition = await prisma.workflowTransition.findFirst({
where: {
id: transitionId,
fromStep: {
workflowId,
workflow: {projectId},
},
},
include: {
fromStep: true,
toStep: true,
},
});
if (!transition) {
throw new HttpException(404, 'Transition not found');
}
// Check if workflow is enabled and has active executions that could be affected
if (workflow.enabled) {
// Get all steps that would become orphaned by removing this transition
const allSteps = await prisma.workflowStep.findMany({
where: {workflowId},
include: {outgoingTransitions: true, incomingTransitions: true},
});
// Build adjacency map without this transition
const adjacencyMap = new Map<string, string[]>();
for (const s of allSteps) {
adjacencyMap.set(
s.id,
s.outgoingTransitions.filter(t => t.id !== transitionId).map(t => t.toStepId),
);
}
// Find all steps reachable from the toStep (downstream)
const downstreamSteps = new Set<string>([transition.toStepId]);
const queue = [transition.toStepId];
while (queue.length > 0) {
const currentStepId = queue.shift()!;
const outgoingStepIds = adjacencyMap.get(currentStepId) || [];
for (const nextStepId of outgoingStepIds) {
if (!downstreamSteps.has(nextStepId)) {
downstreamSteps.add(nextStepId);
queue.push(nextStepId);
}
}
}
// Check if any active executions are on the toStep or downstream steps
const executionsAffected = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: {in: Array.from(downstreamSteps)},
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
if (executionsAffected > 0) {
throw new HttpException(
409,
`Cannot delete transition from "${transition.fromStep.name}" to "${transition.toStep.name}" ` +
`while ${executionsAffected} execution(s) are on affected steps. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
}
await prisma.workflowTransition.delete({
where: {id: transitionId},
});
}
/**
* Start a workflow execution for a contact
*/
public static async startExecution(
projectId: string,
workflowId: string,
contactId: string,
context?: Prisma.JsonValue,
): Promise<WorkflowExecution> {
// Verify workflow exists, is enabled, and belongs to project
const workflow = await this.get(projectId, workflowId);
if (!workflow.enabled) {
throw new HttpException(400, 'Workflow is not enabled');
}
// Verify contact belongs to project
const contact = await prisma.contact.findFirst({
where: {
id: contactId,
projectId,
},
});
if (!contact) {
throw new HttpException(404, 'Contact not found');
}
// Check re-entry rules
if (!workflow.allowReentry) {
// If re-entry is not allowed, check if contact has ANY execution (regardless of status)
const existingExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
},
});
if (existingExecution) {
throw new HttpException(
409,
`Workflow does not allow re-entry. Contact already has execution (${existingExecution.status})`,
);
}
} else {
// If re-entry is allowed, only check if there's a currently RUNNING execution
const runningExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
status: WorkflowExecutionStatus.RUNNING,
},
});
if (runningExecution) {
throw new HttpException(409, 'Workflow is already running for this contact');
}
}
// Find the trigger step
const triggerStep = workflow.steps.find(step => step.type === 'TRIGGER');
if (!triggerStep) {
throw new HttpException(400, 'Workflow has no trigger step');
}
// Create workflow execution
const execution = await prisma.workflowExecution.create({
data: {
workflowId,
contactId,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: context ?? Prisma.JsonNull,
},
});
// Start executing the workflow asynchronously
// Don't await - let it run in background
WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => {
signale.error('Error executing workflow:', error);
});
return execution;
}
/**
* Get workflow executions with filtering
*/
public static async listExecutions(
projectId: string,
workflowId: string,
page = 1,
pageSize = 20,
status?: WorkflowExecutionStatus,
) {
// Verify workflow belongs to project
await this.get(projectId, workflowId);
const skip = (page - 1) * pageSize;
const where: Prisma.WorkflowExecutionWhereInput = {
workflowId,
...(status ? {status} : {}),
};
const [executions, total] = await Promise.all([
prisma.workflowExecution.findMany({
where,
skip,
take: pageSize,
orderBy: {startedAt: 'desc'},
include: {
contact: {
select: {
id: true,
email: true,
},
},
currentStep: {
select: {
id: true,
name: true,
type: true,
},
},
},
}),
prisma.workflowExecution.count({where}),
]);
return {
executions,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Get a single execution with details
*/
public static async getExecution(
projectId: string,
workflowId: string,
executionId: string,
): Promise<WorkflowExecutionWithDetails> {
const execution = await prisma.workflowExecution.findFirst({
where: {
id: executionId,
workflowId,
workflow: {projectId},
},
include: {
workflow: true,
contact: {
select: {
id: true,
email: true,
},
},
currentStep: true,
stepExecutions: {
include: {
step: {
select: {
id: true,
name: true,
type: true,
},
},
},
orderBy: {createdAt: 'asc'},
},
},
});
if (!execution) {
throw new HttpException(404, 'Workflow execution not found');
}
return execution;
}
/**
* Cancel a workflow execution
*/
public static async cancelExecution(
projectId: string,
workflowId: string,
executionId: string,
): Promise<WorkflowExecution> {
// Verify execution exists
await this.getExecution(projectId, workflowId, executionId);
return prisma.workflowExecution.update({
where: {id: executionId},
data: {
status: WorkflowExecutionStatus.CANCELLED,
completedAt: new Date(),
exitReason: 'Cancelled by user',
},
});
}
/**
* Cancel all active executions for a workflow
*/
public static async cancelAllExecutions(projectId: string, workflowId: string): Promise<{cancelled: number}> {
// Verify workflow exists and belongs to project
await this.get(projectId, workflowId);
// Cancel all running and waiting executions
const result = await prisma.workflowExecution.updateMany({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
data: {
status: WorkflowExecutionStatus.CANCELLED,
completedAt: new Date(),
exitReason: 'Cancelled by user (bulk cancel)',
},
});
return {cancelled: result.count};
}
/**
* Get all available fields for workflow conditions (contact fields + event fields)
*/
public static async getAvailableFields(projectId: string, eventName?: string) {
// Get contact fields with types (standard + custom data fields)
const contactFieldsWithTypes = await ContactService.getAvailableFields(projectId);
// Build typed field list with 'contact.' prefix
const contactFields = contactFieldsWithTypes.map(f => ({
field: `contact.${f.field}`,
type: f.type,
category: f.field.startsWith('data.') ? 'Custom Data' : 'Contact Fields',
}));
// Get event fields by analyzing actual event data
// Event fields are treated as dynamic (unknown type at runtime)
const eventFieldNames = await EventService.getAvailableEventFields(projectId, eventName);
const eventFields = eventFieldNames.map(field => ({
field,
type: 'string' as const, // Event fields default to string, can contain any JSON value
category: 'Event Data',
}));
// Combine all fields
const allFields = [...contactFields, ...eventFields].sort((a, b) => a.field.localeCompare(b.field));
// Also return legacy format for backwards compatibility
const fieldNames = allFields.map(f => f.field);
return {
fields: fieldNames,
typedFields: allFields,
count: allFields.length,
};
}
/**
* Check if a workflow has active executions
*/
private static async hasActiveExecutions(workflowId: string): Promise<number> {
return prisma.workflowExecution.count({
where: {
workflowId,
status: {
in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING],
},
},
});
}
}
@@ -0,0 +1,781 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
// Mock the stripe client to avoid actual Stripe API calls
vi.mock('../../app/stripe.js', () => ({
stripe: {
customers: {
retrieve: vi.fn().mockResolvedValue({
deleted: false,
currency: 'usd',
}),
},
},
}));
describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
});
describe('Revenue Protection - Limit Enforcement', () => {
it('should BLOCK transactional emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitTransactional: 5},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Attempt to send 6th email should fail
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK campaign emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 3},
});
// Create 3 campaign emails
for (let i = 0; i < 3; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// 4th should fail
const campaign = await factories.createCampaign({projectId});
await expect(
EmailService.sendCampaignEmail({
projectId,
contactId,
campaignId: campaign.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK workflow emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitWorkflows: 2},
});
// Create 2 workflow emails
for (let i = 0; i < 2; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// 3rd should fail
await expect(
EmailService.sendWorkflowEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should ALLOW emails when limit is null (unlimited)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitTransactional: null,
// Set one other limit to trigger per-category mode (not free tier)
billingLimitCampaigns: 1000,
},
});
// Create 100 emails
for (let i = 0; i < 100; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Should still allow more
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(result.allowed).toBe(true);
expect(result.limit).toBeNull();
});
it('should enforce limits independently per source type', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitTransactional: 5,
billingLimitCampaigns: 5,
billingLimitWorkflows: 5,
},
});
// Max out transactional
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Transactional should be blocked
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(transactionalCheck.allowed).toBe(false);
// Campaign should still be allowed
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(true);
// Workflow should still be allowed
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
});
});
describe('Warning Threshold (80%)', () => {
it('should warn when usage reaches 80% of limit', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(true);
expect(result.percentage).toBeGreaterThanOrEqual(80);
expect(result.message).toMatch(/warning/i);
});
it('should not warn when usage is below 80%', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(false);
expect(result.message).toBeUndefined();
});
});
describe('Cache Performance & Fallback', () => {
it('should use cached usage counts to avoid DB queries', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// First call - should query DB and cache
const usage1 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Add email directly to DB (bypassing cache increment)
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
// Second call within cache TTL - should return cached value (not reflect new email)
const usage2 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(usage2).toBe(usage1); // Same as cached value
});
it('should increment cache after successful email send', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
await BillingLimitService.invalidateCache(projectId);
const initialUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Send email (should increment cache)
await EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
const finalUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(finalUsage).toBe(initialUsage + 1);
});
it('should handle Redis failure gracefully without blocking emails', async () => {
// Mock Redis failure
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('Redis connection failed'));
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Should fall back to DB and still work
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
});
it('should handle invalid project ID gracefully', async () => {
// Non-existent project should not crash, should allow email (fail-open)
const result = await BillingLimitService.checkLimit('non-existent-project', EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.usage).toBe(0);
expect(result.limit).toBeNull();
});
});
describe('Monthly Reset Behavior', () => {
it('should only count emails from current calendar month', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
const lastMonth = new Date();
lastMonth.setMonth(lastMonth.getMonth() - 1);
await prisma.email.create({
data: {
projectId,
contactId,
subject: 'Old',
body: 'Old',
from: 'test@example.com',
sourceType: EmailSourceType.CAMPAIGN,
status: 'SENT',
createdAt: lastMonth,
},
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const usage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Should only count this month's email
expect(usage).toBe(1);
});
});
describe('Complete Limits Overview', () => {
it('should return all category limits and usage', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: 100,
billingLimitCampaigns: 200,
billingLimitTransactional: null, // Unlimited
},
});
// Create various emails
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
expect(limits.workflows.limit).toBe(100);
expect(limits.workflows.usage).toBe(1);
expect(limits.workflows.percentage).toBe(1);
expect(limits.workflows.isWarning).toBe(false);
expect(limits.workflows.isBlocked).toBe(false);
expect(limits.campaigns.limit).toBe(200);
expect(limits.campaigns.usage).toBe(2);
expect(limits.campaigns.percentage).toBe(1);
expect(limits.transactional.limit).toBeNull();
expect(limits.transactional.usage).toBe(0);
expect(limits.transactional.percentage).toBe(0);
});
});
describe('Race Condition Behavior', () => {
it('should check limits before each email send (may allow some concurrent sends)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 8 existing emails (80% of limit)
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// Try to send 5 emails simultaneously
const promises = Array.from({length: 5}, () =>
EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
);
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
// Note: Due to race conditions, multiple may succeed before limit is enforced
// The limit check happens at send time, not atomically
// At least some should succeed (we're under limit when we start)
expect(successful).toBeGreaterThan(0);
// Eventually, some should fail once limit is hit
// Total emails created should not greatly exceed limit
const totalEmails = await prisma.email.count({
where: {projectId, sourceType: EmailSourceType.CAMPAIGN},
});
// Should be close to limit (8 existing + some new <= ~13 due to races)
expect(totalEmails).toBeLessThanOrEqual(15);
});
});
describe('Free Tier Total Limit (1000 emails/month across all types)', () => {
it('should enforce 1000 email total limit for free tier projects', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project has no subscription and no custom limits set
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 1000 emails total across different types
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 400; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// All three types should now be blocked since total is 1000
await expect(
EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
await expect(
EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
});
it('should count all email types towards free tier total limit', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 200 of each type (600 total)
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Check limits for each type - all should report 600 total usage
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
// All types should see the same total usage (600) and same limit (1000)
expect(workflowCheck.usage).toBe(600);
expect(workflowCheck.limit).toBe(1000);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.percentage).toBe(60);
expect(campaignCheck.usage).toBe(600);
expect(campaignCheck.limit).toBe(1000);
expect(campaignCheck.allowed).toBe(true);
expect(transactionalCheck.usage).toBe(600);
expect(transactionalCheck.limit).toBe(1000);
expect(transactionalCheck.allowed).toBe(true);
});
it('should show warning at 80% of free tier total limit (800 emails)', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 800 emails spread across types
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 250; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 250; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// All types should show warning
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.warning).toBe(true);
expect(workflowCheck.usage).toBe(800);
expect(workflowCheck.percentage).toBeGreaterThanOrEqual(80);
expect(workflowCheck.message).toMatch(/warning/i);
});
it('should allow free tier to send different distribution of email types', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 900 transactional, 50 campaigns, 49 workflows (999 total - under limit)
for (let i = 0; i < 900; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
for (let i = 0; i < 50; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 49; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// Should still allow one more email of any type
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.usage).toBe(999);
// Send the 1000th email (should succeed)
await EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
await BillingLimitService.invalidateCache(projectId);
// Now all types should be blocked
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(false);
expect(campaignCheck.usage).toBe(1000);
});
it('should distinguish free tier from paid tier with per-category limits', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Set custom per-category limits (paid tier behavior)
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: 100,
billingLimitCampaigns: 200,
billingLimitTransactional: 300,
},
});
// Create 150 workflow emails (exceeds workflow limit but under total)
for (let i = 0; i < 150; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// Workflow should be blocked (150 > 100)
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(false);
expect(workflowCheck.usage).toBe(150);
expect(workflowCheck.limit).toBe(100);
// But campaigns should still be allowed (independent limit)
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(true);
expect(campaignCheck.usage).toBe(0);
expect(campaignCheck.limit).toBe(200);
});
it('should show shared limit for all categories in getLimitsAndUsage for free tier', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create various emails
for (let i = 0; i < 100; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 150; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
// All categories should show the same total usage (450) and limit (1000)
expect(limits.workflows.limit).toBe(1000);
expect(limits.workflows.usage).toBe(450);
expect(limits.workflows.percentage).toBe(45);
expect(limits.campaigns.limit).toBe(1000);
expect(limits.campaigns.usage).toBe(450);
expect(limits.transactional.limit).toBe(1000);
expect(limits.transactional.usage).toBe(450);
});
});
});
@@ -0,0 +1,592 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock STRIPE_ENABLED for billing limit tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
describe('CampaignService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('create', () => {
it('should create a campaign with ALL audience type', async () => {
const campaign = await CampaignService.create(projectId, {
name: 'Test Campaign',
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.ALL,
});
expect(campaign).toBeDefined();
expect(campaign.name).toBe('Test Campaign');
expect(campaign.status).toBe(CampaignStatus.DRAFT);
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
});
it('should create a campaign with SEGMENT audience type', async () => {
const segment = await factories.createSegment(projectId, {name: 'VIP Users'});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.segmentId).toBe(segment.id);
});
it('should throw error when creating SEGMENT campaign without segmentId', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
}),
).rejects.toThrow('Segment ID is required');
});
it('should throw error when segment does not exist', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: 'non-existent-segment',
}),
).rejects.toThrow('Segment not found');
});
});
describe('update', () => {
it('should update a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
name: 'Original Name',
status: CampaignStatus.DRAFT,
});
const updated = await CampaignService.update(projectId, campaign.id, {
name: 'Updated Name',
subject: 'Updated Subject',
});
expect(updated.name).toBe('Updated Name');
expect(updated.subject).toBe('Updated Subject');
});
it('should throw error when updating non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.update(projectId, campaign.id, {name: 'New Name'})).rejects.toThrow(
'Cannot update campaign that is sending or has been sent',
);
});
});
describe('delete', () => {
it('should delete a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await CampaignService.delete(projectId, campaign.id);
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
expect(deleted).toBeNull();
});
it('should not delete a non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.delete(projectId, campaign.id)).rejects.toThrow('Can only delete draft campaigns');
});
});
describe('duplicate', () => {
it('should duplicate a campaign with (Copy) suffix', async () => {
const original = await factories.createCampaign({
projectId,
name: 'Original Campaign',
});
const duplicate = await CampaignService.duplicate(projectId, original.id);
expect(duplicate.name).toBe('Original Campaign (Copy)');
expect(duplicate.subject).toBe(original.subject);
expect(duplicate.body).toBe(original.body);
expect(duplicate.status).toBe(CampaignStatus.DRAFT);
expect(duplicate.id).not.toBe(original.id);
});
});
describe('list', () => {
it('should list campaigns with pagination', async () => {
// Create 25 campaigns using bulk insert to avoid memory issues
const campaignData = Array.from({length: 25}, (_, i) => ({
projectId,
name: `Campaign ${i}`,
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
status: 'DRAFT' as const,
}));
await prisma.campaign.createMany({data: campaignData});
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
expect(result.campaigns).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
expect(result.page).toBe(1);
});
it('should filter campaigns by status', async () => {
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.campaigns).toHaveLength(2);
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
describe('get', () => {
it('should get a campaign by id', async () => {
const campaign = await factories.createCampaign({projectId, name: 'Test Campaign'});
const retrieved = await CampaignService.get(projectId, campaign.id);
expect(retrieved.id).toBe(campaign.id);
expect(retrieved.name).toBe('Test Campaign');
});
it('should throw error when campaign does not exist', async () => {
await expect(CampaignService.get(projectId, 'non-existent-id')).rejects.toThrow('Campaign not found');
});
});
describe('Campaign + Segment Integration', () => {
it('should create campaign targeting a segment', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.SEGMENT);
expect(campaign.segmentId).toBe(segment.id);
});
it('should only send to contacts matching segment criteria', async () => {
// Create contacts - some match segment, some don't
const vipContact1 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const vipContact2 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const regularContact = await factories.createContact({
projectId,
subscribed: true,
data: {vip: false},
});
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// In a real scenario, the campaign processor would create emails
// For this test, we manually check which contacts match the segment filters
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const contacts = allContacts.filter(c => (c.data as Record<string, unknown>)?.vip === true);
// Should only include VIP contacts
expect(contacts).toHaveLength(2);
const contactIds = contacts.map(c => c.id);
expect(contactIds).toContain(vipContact1.id);
expect(contactIds).toContain(vipContact2.id);
expect(contactIds).not.toContain(regularContact.id);
});
it('should exclude unsubscribed contacts from segment campaigns', async () => {
const subscribedVip = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const _unsubscribedVip = await factories.createContact({
projectId,
subscribed: false,
data: {vip: true},
});
// Segment that requires BOTH vip AND subscribed
const segment = await factories.createSegment(projectId, {
name: 'Subscribed VIP Users',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// Verify only subscribed VIP is targeted
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const matching = allContacts.filter(
c => (c.data as Record<string, unknown>)?.vip === true && c.subscribed === true,
);
expect(matching).toHaveLength(1);
expect(matching[0].id).toBe(subscribedVip.id);
});
it('should handle campaigns for ALL audience type', async () => {
// Create mix of contacts
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: false}); // Should be excluded
const campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.ALL,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
expect(campaign.segmentId).toBeNull();
// Verify ALL campaigns should target subscribed contacts only
const subscribedContacts = await prisma.contact.findMany({
where: {projectId, subscribed: true},
});
expect(subscribedContacts).toHaveLength(2);
});
});
describe('Campaign Audience Validation', () => {
it('should calculate correct recipient count for segment campaigns', async () => {
// Create 5 contacts matching segment
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro'},
});
}
// Create 3 contacts not matching
for (let i = 0; i < 3; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free'},
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
const matching = await prisma.contact.count({
where: {
projectId,
data: {
path: ['plan'],
equals: 'pro',
},
},
});
expect(matching).toBe(5);
});
});
describe('send', () => {
it('should throw error when campaign has no recipients', async () => {
// Create campaign with no contacts in project
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
});
it('should send campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Create 5 subscribed contacts
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
// Create campaign targeting all contacts
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (5 recipients < 100 limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw 403 error when campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails (usage = 5)
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 15 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
});
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 20
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 20},
});
// Create 15 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 15; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 25 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
/Cannot schedule campaign.*exceed billing limit/i,
);
});
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
// Set billing limit to null (unlimited)
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: null},
});
// Create 1000 subscribed contacts
for (let i = 0; i < 1000; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (no limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(1000);
});
it('should allow campaign that exactly reaches billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
for (let i = 0; i < 4; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully because 5 + 5 = 10 (exactly at limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw error when campaign has already been sent', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
'Campaign has already been sent or is currently sending',
);
});
it('should schedule campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 50},
});
// Create 10 subscribed contacts
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should schedule successfully (10 recipients < 50 limit)
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
expect(scheduledCampaign.totalRecipients).toBe(10);
});
});
});
@@ -0,0 +1,709 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('ContactService - Duplicate Prevention & Data Merging', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Duplicate Email Prevention', () => {
it('should REJECT creating duplicate email in same project', async () => {
const email = 'test@example.com';
await ContactService.create(projectId, {email});
await expect(ContactService.create(projectId, {email})).rejects.toThrow(/already exists/i);
});
it('should ALLOW same email in different projects (multi-tenancy)', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const email = 'test@example.com';
const contact1 = await ContactService.create(project1.id, {email});
const contact2 = await ContactService.create(project2.id, {email});
expect(contact1.id).not.toBe(contact2.id);
expect(contact1.email).toBe(email);
expect(contact2.email).toBe(email);
});
it('should REJECT updating contact to duplicate email in same project', async () => {
const email1 = 'user1@example.com';
const email2 = 'user2@example.com';
await ContactService.create(projectId, {email: email1});
const contact2 = await ContactService.create(projectId, {email: email2});
await expect(ContactService.update(projectId, contact2.id, {email: email1})).rejects.toThrow(/already exists/i);
});
it('should ALLOW updating contact to same email (no-op)', async () => {
const email = 'test@example.com';
const contact = await ContactService.create(projectId, {email});
const updated = await ContactService.update(projectId, contact.id, {
email,
data: {firstName: 'John'},
});
expect(updated.email).toBe(email);
});
it('should handle race condition when creating same email simultaneously', async () => {
const email = 'race@example.com';
const promises = Array.from({length: 10}, () => ContactService.create(projectId, {email}));
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
expect(successful).toBe(1);
expect(failed).toBe(9);
const contacts = await prisma.contact.findMany({
where: {projectId, email},
});
expect(contacts).toHaveLength(1);
});
});
describe('Upsert Data Merging Logic', () => {
it('should merge new data with existing data without losing fields', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
expect(contact.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
const updated = await ContactService.upsert(projectId, email, {
lastName: 'Doe',
company: 'Acme Inc',
});
expect(updated.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
lastName: 'Doe',
company: 'Acme Inc',
});
});
it('should overwrite existing fields with new values', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {
plan: 'free',
credits: 100,
});
const updated = await ContactService.upsert(projectId, email, {
plan: 'pro',
credits: 1000,
});
expect(updated.data).toMatchObject({
plan: 'pro',
credits: 1000,
});
});
it('should NOT persist non-persistent data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
tempToken: {value: 'abc123', persistent: false},
oneTimeCode: {value: '123456', persistent: false},
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('tempToken');
expect(contact.data).not.toHaveProperty('oneTimeCode');
});
it('should ignore reserved fields (plunk_id, plunk_email)', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plunk_id: 'malicious-id',
plunk_email: 'hacker@evil.com',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('plunk_id');
expect(contact.data).not.toHaveProperty('plunk_email');
});
it('should handle null data gracefully', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, undefined);
expect(contact.email).toBe(email);
});
it('should handle empty object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {});
expect(contact.email).toBe(email);
});
it('should update subscription status independently of data', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {firstName: 'John'}, true);
const subscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(subscribed?.subscribed).toBe(true);
await ContactService.upsert(projectId, email, {lastName: 'Doe'}, false);
const unsubscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(unsubscribed?.subscribed).toBe(false);
expect(unsubscribed?.data).toMatchObject({
firstName: 'John',
lastName: 'Doe',
});
});
});
describe('getMergedData - Template Rendering', () => {
it('should include reserved plunk_id and plunk_email fields', async () => {
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
const merged = ContactService.getMergedData(contact);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('test@example.com');
});
it('should merge persistent contact data', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John', plan: 'pro'},
});
const merged = ContactService.getMergedData(contact);
expect(merged.firstName).toBe('John');
expect(merged.plan).toBe('pro');
});
it('should merge temporary (non-persistent) data for rendering', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John'},
});
const temporaryData = {
resetToken: {value: 'temp123', persistent: false},
resetUrl: {value: 'https://app.com/reset?token=temp123', persistent: false},
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.firstName).toBe('John');
expect(merged.resetToken).toBe('temp123');
expect(merged.resetUrl).toBe('https://app.com/reset?token=temp123');
});
it('should override persistent data with temporary data', async () => {
const contact = await factories.createContact({
projectId,
data: {name: 'Stored Name'},
});
const temporaryData = {
name: 'Override Name',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.name).toBe('Override Name');
});
it('should not allow overriding reserved fields via temporary data', async () => {
const contact = await factories.createContact({
projectId,
email: 'real@example.com',
});
const temporaryData = {
plunk_id: 'fake-id',
plunk_email: 'fake@example.com',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('real@example.com');
});
});
describe('Data Integrity Edge Cases', () => {
it('should handle deeply nested object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
expect(contact.data).toMatchObject({
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
});
it('should handle array data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
tags: ['vip', 'beta-tester', 'early-adopter'],
});
expect(contact.data).toMatchObject({
tags: ['vip', 'beta-tester', 'early-adopter'],
});
});
it('should handle special characters in field names', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
'custom-field': 'value',
'field.with.dots': 'value2',
'field with spaces': 'value3',
});
expect(contact.data).toHaveProperty('custom-field', 'value');
expect(contact.data).toHaveProperty('field.with.dots', 'value2');
expect(contact.data).toHaveProperty('field with spaces', 'value3');
});
it('should handle boolean, number, and string values', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
expect(contact.data).toMatchObject({
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
});
it('should handle null values in data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: null,
lastName: 'Doe',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('middleName', null);
expect(contact.data).toHaveProperty('lastName', 'Doe');
});
});
describe('Contact CRUD Operations', () => {
it('should find contact by email', async () => {
const email = 'find@example.com';
const created = await ContactService.create(projectId, {email});
const found = await ContactService.findByEmail(projectId, email);
expect(found?.id).toBe(created.id);
expect(found?.email).toBe(email);
});
it('should return null when contact not found by email', async () => {
const found = await ContactService.findByEmail(projectId, 'nonexistent@example.com');
expect(found).toBeNull();
});
it('should get contact count for project', async () => {
await factories.createContact({projectId});
await factories.createContact({projectId});
await factories.createContact({projectId});
const count = await ContactService.count(projectId);
expect(count).toBe(3);
});
it('should delete contact', async () => {
const contact = await factories.createContact({projectId});
await ContactService.delete(projectId, contact.id);
const deleted = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(deleted).toBeNull();
});
it('should throw 404 when deleting non-existent contact', async () => {
await expect(ContactService.delete(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
it('should throw 404 when getting non-existent contact', async () => {
await expect(ContactService.get(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
});
describe('Public Contact Operations (Unsubscribe)', () => {
it('should get contact by ID without project authentication', async () => {
const contact = await factories.createContact({projectId});
const fetched = await ContactService.getById(contact.id);
expect(fetched.id).toBe(contact.id);
expect(fetched.email).toBe(contact.email);
});
it('should subscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false,
});
await ContactService.subscribe(contact.id);
const subscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(subscribed?.subscribed).toBe(true);
});
it('should unsubscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
await ContactService.unsubscribe(contact.id);
const unsubscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribed?.subscribed).toBe(false);
});
});
describe('Bulk Contact Operations', () => {
describe('bulkSubscribe', () => {
it('should subscribe multiple unsubscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const contact3 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => c.subscribed)).toBe(true);
});
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [
unsubscribed1.id,
unsubscribed2.id,
alreadySubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: false});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(true);
expect(otherContactAfter?.subscribed).toBe(false);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkSubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
);
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkSubscribe(projectId, contactIds);
expect(result.updated).toBe(150);
const updatedContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
});
});
describe('bulkUnsubscribe', () => {
it('should unsubscribe multiple subscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const contact3 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => !c.subscribed)).toBe(true);
});
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
const subscribed1 = await factories.createContact({projectId, subscribed: true});
const subscribed2 = await factories.createContact({projectId, subscribed: true});
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [
subscribed1.id,
subscribed2.id,
alreadyUnsubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: true});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(false);
expect(otherContactAfter?.subscribed).toBe(true);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
});
describe('bulkDelete', () => {
it('should delete multiple contacts', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
const contact3 = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.deleted).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts).toHaveLength(0);
});
it('should only delete contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId});
const otherContact = await factories.createContact({projectId: otherProject.id});
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
expect(result.deleted).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter).toBeNull();
expect(otherContactAfter).not.toBeNull();
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkDelete(projectId, []);
expect(result.deleted).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.deleted).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkDelete(projectId, contactIds);
expect(result.deleted).toBe(200);
const remainingContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(remainingContacts).toHaveLength(0);
});
it('should delete both subscribed and unsubscribed contacts', async () => {
const subscribed = await factories.createContact({projectId, subscribed: true});
const unsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
expect(result.deleted).toBe(2);
});
it('should handle partial matches (some exist, some do not)', async () => {
const existingContact = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
expect(result.deleted).toBe(1);
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
expect(contact).toBeNull();
});
});
describe('Bulk Operations - Project Isolation', () => {
it('should never leak contacts between projects in bulk operations', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [
p1Contact1.id,
p1Contact2.id,
p2Contact1.id,
p2Contact2.id,
]);
const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id},
});
const p2ContactsAfter = await prisma.contact.findMany({
where: {projectId: project2.id},
});
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
});
});
});
});
@@ -0,0 +1,564 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js';
import * as SESService from '../SESService.js';
/**
* Unit tests for DomainService
* Focuses on business logic, validation, and edge cases
*/
describe('DomainService', () => {
const prisma = getPrismaClient();
beforeEach(() => {
// Mock SES service calls to avoid AWS API calls
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
status: 'Success',
tokens: ['token1', 'token2', 'token3'],
});
});
// ========================================
// ADD DOMAIN
// ========================================
describe('addDomain', () => {
it('should add a domain and initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'example.com';
const result = await DomainService.addDomain(project.id, domain);
expect(result.domain).toBe(domain);
expect(result.projectId).toBe(project.id);
expect(result.verified).toBe(false);
expect(result.dkimTokens).toEqual(['token1', 'token2', 'token3']);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
it('should call AWS SES to initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'test-domain.com';
await DomainService.addDomain(project.id, domain);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
});
// ========================================
// CHECK DOMAIN OWNERSHIP
// ========================================
describe('checkDomainOwnership', () => {
it('should return exists: false for non-existent domain', async () => {
const result = await DomainService.checkDomainOwnership('non-existent.com', 'user-id');
expect(result).toEqual({exists: false});
});
it('should return project info when domain exists', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'existing.com');
const result = await DomainService.checkDomainOwnership('existing.com', user.id);
expect(result.exists).toBe(true);
expect(result.projectId).toBe(project.id);
expect(result.projectName).toBe(project.name);
expect(result.isMember).toBe(true);
});
it('should indicate isMember: false when user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
await DomainService.addDomain(project1.id, 'restricted.com');
const result = await DomainService.checkDomainOwnership('restricted.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(false);
});
it('should indicate isMember: true when user is a member', async () => {
const {project} = await factories.createUserWithProject();
const user2 = await factories.createUser();
await prisma.membership.create({
data: {
userId: user2.id,
projectId: project.id,
role: 'MEMBER',
},
});
await DomainService.addDomain(project.id, 'team-domain.com');
const result = await DomainService.checkDomainOwnership('team-domain.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(true);
});
});
// ========================================
// VERIFY EMAIL DOMAIN
// ========================================
describe('verifyEmailDomain', () => {
it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject();
await expect(
DomainService.verifyEmailDomain('sender@unregistered.com', project.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@unregistered.com', project.id),
).rejects.toThrow(/not registered/i);
});
it('should throw error when domain belongs to different project', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project1.id, 'project1.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
await expect(
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
).rejects.toThrow(/belongs to a different project/i);
});
it('should throw error when domain is not verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified.com');
await expect(
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
).rejects.toThrow(/not verified/i);
});
it('should return domain when all checks pass', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'verified.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@verified.com', project.id);
expect(result.domain).toBe('verified.com');
expect(result.verified).toBe(true);
expect(result.projectId).toBe(project.id);
});
it('should extract domain correctly from various email formats', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Test various email formats
const result1 = await DomainService.verifyEmailDomain('user@example.com', project.id);
const result2 = await DomainService.verifyEmailDomain('admin@example.com', project.id);
const result3 = await DomainService.verifyEmailDomain('support+tag@example.com', project.id);
expect(result1.domain).toBe('example.com');
expect(result2.domain).toBe('example.com');
expect(result3.domain).toBe('example.com');
});
});
// ========================================
// GET DOMAIN BY ID
// ========================================
describe('id', () => {
it('should return domain by id', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'test.com');
const result = await DomainService.id(domain.id);
expect(result).not.toBeNull();
expect(result?.id).toBe(domain.id);
expect(result?.domain).toBe('test.com');
});
it('should return null for non-existent id', async () => {
const result = await DomainService.id('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
});
});
// ========================================
// GET PROJECT DOMAINS
// ========================================
describe('getProjectDomains', () => {
it('should return all domains for a project', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'domain1.com');
await DomainService.addDomain(project.id, 'domain2.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(2);
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com']);
});
it('should return domains ordered by creation date (newest first)', async () => {
const {project} = await factories.createUserWithProject();
// Add domains with slight delay to ensure different timestamps
const domain1 = await DomainService.addDomain(project.id, 'first.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain2 = await DomainService.addDomain(project.id, 'second.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain3 = await DomainService.addDomain(project.id, 'third.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains[0].id).toBe(domain3.id); // Newest first
expect(domains[1].id).toBe(domain2.id);
expect(domains[2].id).toBe(domain1.id);
});
it('should return empty array for project with no domains', async () => {
const {project} = await factories.createUserWithProject();
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toEqual([]);
});
});
// ========================================
// GET VERIFIED DOMAINS
// ========================================
describe('getVerifiedDomains', () => {
it('should return only verified domains', async () => {
const {project} = await factories.createUserWithProject();
const domain1 = await DomainService.addDomain(project.id, 'verified1.com');
await DomainService.addDomain(project.id, 'unverified.com');
const domain3 = await DomainService.addDomain(project.id, 'verified2.com');
// Mark two as verified
await prisma.domain.update({
where: {id: domain1.id},
data: {verified: true},
});
await prisma.domain.update({
where: {id: domain3.id},
data: {verified: true},
});
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toHaveLength(2);
expect(verifiedDomains.map(d => d.domain).sort()).toEqual(['verified1.com', 'verified2.com']);
});
it('should return empty array when no domains are verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified1.com');
await DomainService.addDomain(project.id, 'unverified2.com');
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toEqual([]);
});
});
// ========================================
// CHECK VERIFICATION
// ========================================
describe('checkVerification', () => {
it('should check verification status with AWS SES', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'check-verification.com');
const result = await DomainService.checkVerification(domain.id);
expect(result.domain).toBe('check-verification.com');
expect(result.tokens).toEqual(['token1', 'token2', 'token3']);
expect(result.status).toBe('Success');
expect(result.verified).toBe(true);
expect(SESService.getDomainVerificationAttributes).toHaveBeenCalledWith('check-verification.com');
});
it('should update domain to verified when SES returns Success', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'newly-verified.com');
expect(domain.verified).toBe(false);
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(true);
});
it('should update domain to unverified when SES returns Pending', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'pending-domain.com');
// Manually mark as verified
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Mock SES to return Pending
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValueOnce({
status: 'Pending',
tokens: ['token1', 'token2', 'token3'],
});
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(false);
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
});
});
// ========================================
// REMOVE DOMAIN
// ========================================
describe('removeDomain', () => {
it('should remove domain when not in use', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'remove-me.com');
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error when domain is used in templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-template.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender@used-in-template.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*template/i,
);
});
it('should throw error when domain is used in active campaigns', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@used-in-campaign.com',
status: 'DRAFT',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*campaign/i,
);
});
it('should allow removal when campaign is SENT (completed)', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'completed-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@completed-campaign.com',
status: 'SENT',
});
// Should not throw
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
});
it('should check usage in multiple templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'multi-use.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender1@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender2@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender3@multi-use.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/3 template/i);
});
});
// ========================================
// EDGE CASES AND ERROR HANDLING
// ========================================
describe('Edge Cases', () => {
it('should handle emails with plus addressing', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('user+tag@example.com', project.id);
expect(result.domain).toBe('example.com');
});
it('should handle subdomain correctly', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'mail.example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@mail.example.com', project.id);
expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail
await expect(
DomainService.verifyEmailDomain('sender@other.example.com', project.id),
).rejects.toThrow(/not registered/i);
});
it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should handle email with multiple @ signs', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('user@@example.com', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
});
// ========================================
// CONCURRENCY AND RACE CONDITIONS
// ========================================
describe('Concurrency', () => {
it('should handle concurrent domain additions to same project', async () => {
const {project} = await factories.createUserWithProject();
// Add multiple domains concurrently
const results = await Promise.all([
DomainService.addDomain(project.id, 'concurrent1.com'),
DomainService.addDomain(project.id, 'concurrent2.com'),
DomainService.addDomain(project.id, 'concurrent3.com'),
]);
expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
});
it('should handle concurrent ownership checks', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'concurrent-check.com');
// Multiple concurrent ownership checks
const results = await Promise.all([
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
]);
// All should return consistent results
expect(results.every(r => r.exists)).toBe(true);
expect(results.every(r => r.isMember)).toBe(true);
});
});
});
@@ -0,0 +1,805 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
import {sendRawEmail} from '../SESService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock SES service
vi.mock('../SESService', () => ({
sendRawEmail: vi.fn(),
}));
describe('EmailService', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
// Create a verified domain for the project (required for sending emails)
await factories.createDomain({
projectId,
domain: 'example.com',
verified: true,
});
// Mock successful SES send by default
vi.mocked(sendRawEmail).mockResolvedValue({
messageId: 'ses-message-123',
});
});
// ========================================
// SUBSCRIPTION ENFORCEMENT (GDPR)
// ========================================
describe('Subscription Enforcement (GDPR Compliance)', () => {
describe('Marketing Email Protection', () => {
it('should send campaign emails only to subscribed contacts', async () => {
const subscribedContact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: subscribedContact.id,
subject: 'Newsletter',
body: 'Marketing content',
from: 'news@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
});
it('should NOT send workflow marketing emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: 'MARKETING',
});
// Create a workflow and execution for the foreign key
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: marketingTemplate.id,
subject: 'Marketing Email',
body: 'Content',
from: 'test@example.com',
workflowExecutionId: execution.id,
});
expect(email.status).toBe(EmailStatus.FAILED);
expect(email.error).toMatch(/unsubscribed/i);
});
it('should REJECT sending MARKETING template via transactional API to unsubscribed contact', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: 'MARKETING',
});
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: marketingTemplate.id,
subject: 'Marketing disguised as transactional',
body: 'Buy now!',
from: 'test@example.com',
}),
).rejects.toThrow(/cannot send marketing template to unsubscribed contact/i);
});
});
describe('Transactional Email Exemption', () => {
it('should ALLOW transactional emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: transactionalTemplate.id,
subject: 'Password Reset',
body: 'Reset your password',
from: 'noreply@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
it('should ALLOW workflow transactional emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
// Create workflow and execution for the foreign key
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: transactionalTemplate.id,
subject: 'Account Verification',
body: 'Verify your account',
from: 'noreply@example.com',
workflowExecutionId: execution.id,
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
});
describe('Template Type Determines Email Type', () => {
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
const campaign = await factories.createCampaign({projectId});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
templateId: transactionalTemplate.id,
subject: 'Receipt',
body: 'Your receipt',
from: 'billing@example.com',
});
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
});
describe('Unsubscribe via Complaint Webhook', () => {
it('should track when contact unsubscribes via complaint webhook', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'complained');
const unsubscribedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribedContact?.subscribed).toBe(false);
const complainedEmail = await prisma.email.findUnique({
where: {id: email.id},
});
expect(complainedEmail?.status).toBe(EmailStatus.COMPLAINED);
});
});
});
// ========================================
// STATUS TRANSITIONS
// ========================================
describe('Status Transitions & Lifecycle', () => {
describe('Email Creation', () => {
it('should create email with PENDING status', async () => {
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sentAt).toBeNull();
expect(email.messageId).toBeNull();
});
});
describe('PENDING → SENDING → SENT', () => {
it('should transition correctly on successful send', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await EmailService.sendEmail(email.id);
const sent = await prisma.email.findUnique({
where: {id: email.id},
});
expect(sent?.status).toBe(EmailStatus.SENT);
expect(sent?.sentAt).not.toBeNull();
expect(sent?.messageId).toBe('ses-message-123');
});
it('should create email.sent event after successful send', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await EmailService.sendEmail(email.id);
const event = await prisma.event.findFirst({
where: {
projectId,
contactId,
emailId: email.id,
name: 'email.sent',
},
});
expect(event).toBeDefined();
expect(event?.data).toHaveProperty('messageId', 'ses-message-123');
});
});
describe('PENDING → SENDING → FAILED', () => {
it('should mark as FAILED on SES error', async () => {
vi.mocked(sendRawEmail).mockRejectedValue(new Error('SES rate limit exceeded'));
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await expect(EmailService.sendEmail(email.id)).rejects.toThrow();
const failed = await prisma.email.findUnique({
where: {id: email.id},
});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toContain('rate limit');
});
});
describe('Idempotency - Prevent Re-sending', () => {
it('should NOT re-send email if already SENT', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: 'already-sent-123',
});
const sesSpy = vi.mocked(sendRawEmail);
sesSpy.mockClear();
await EmailService.sendEmail(email.id);
expect(sesSpy).not.toHaveBeenCalled();
});
});
describe('Webhook Status Updates', () => {
it('should transition SENT → DELIVERED on delivery webhook', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'delivered');
const delivered = await prisma.email.findUnique({
where: {id: email.id},
});
expect(delivered?.status).toBe(EmailStatus.DELIVERED);
expect(delivered?.deliveredAt).not.toBeNull();
});
it('should transition to OPENED on first open webhook', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'opened');
const opened = await prisma.email.findUnique({
where: {id: email.id},
});
expect(opened?.status).toBe(EmailStatus.OPENED);
expect(opened?.openedAt).not.toBeNull();
expect(opened?.opens).toBe(1);
});
it('should increment opens counter on subsequent opens', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.OPENED,
openedAt: new Date(),
opens: 1,
});
const firstOpenedAt = email.openedAt;
await EmailService.handleWebhookEvent(email.id, 'opened');
const reopened = await prisma.email.findUnique({
where: {id: email.id},
});
expect(reopened?.opens).toBe(2);
expect(reopened?.openedAt).toEqual(firstOpenedAt);
});
it('should transition to CLICKED and track clicks', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'clicked');
const clicked = await prisma.email.findUnique({
where: {id: email.id},
});
expect(clicked?.status).toBe(EmailStatus.CLICKED);
expect(clicked?.clickedAt).not.toBeNull();
expect(clicked?.clicks).toBe(1);
});
it('should transition to BOUNCED on bounce webhook and unsubscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'bounced');
const bounced = await prisma.email.findUnique({
where: {id: email.id},
});
expect(bounced?.status).toBe(EmailStatus.BOUNCED);
expect(bounced?.bouncedAt).not.toBeNull();
// Verify contact was unsubscribed
const unsubscribedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribedContact?.subscribed).toBe(false);
// Verify unsubscription event was tracked
const event = await prisma.event.findFirst({
where: {
projectId,
contactId: contact.id,
name: 'contact.unsubscribed',
},
});
expect(event).not.toBeNull();
expect(event?.data).toMatchObject({reason: 'bounce'});
});
});
});
// ========================================
// EMAIL STATISTICS
// ========================================
describe('Email Statistics', () => {
it('should calculate accurate email stats', async () => {
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
await factories.createEmail({projectId, contactId, status: EmailStatus.DELIVERED});
await factories.createEmail({projectId, contactId, status: EmailStatus.OPENED});
await factories.createEmail({projectId, contactId, status: EmailStatus.CLICKED});
await factories.createEmail({projectId, contactId, status: EmailStatus.BOUNCED});
await factories.createEmail({projectId, contactId, status: EmailStatus.FAILED});
const stats = await EmailService.getStats(projectId);
expect(stats.total).toBe(7);
expect(stats.sent).toBe(2);
expect(stats.delivered).toBe(1);
expect(stats.opened).toBe(1);
expect(stats.clicked).toBe(1);
expect(stats.bounced).toBe(1);
expect(stats.failed).toBe(1);
});
it('should calculate open rate correctly', async () => {
// Create 10 SENT emails, 5 of which are OPENED
// OPENED status counts as both sent and opened
for (let i = 0; i < 5; i++) {
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
}
for (let i = 0; i < 5; i++) {
await factories.createEmail({projectId, contactId, status: EmailStatus.OPENED});
}
const stats = await EmailService.getStats(projectId);
// Total sent = 5 (SENT) + 5 (OPENED) = 10
// Total opened = 5 (OPENED)
// Open rate = 5/10 * 100 = 50%
// BUT: EmailService counts SENT separately from OPENED
// So opened/sent = 5/5 = 100%
// This is a quirk of how EmailStatus works - OPENED doesn't include SENT count
expect(stats.sent).toBe(5); // Only EmailStatus.SENT
expect(stats.opened).toBe(5); // Only EmailStatus.OPENED
expect(stats.total).toBe(10);
});
it('should handle zero sent emails without division by zero', async () => {
await factories.createEmail({projectId, contactId, status: EmailStatus.PENDING});
const stats = await EmailService.getStats(projectId);
expect(stats.openRate).toBe(0);
expect(stats.clickRate).toBe(0);
expect(stats.bounceRate).toBe(0);
});
});
// ========================================
// EMAIL ATTACHMENTS
// ========================================
describe('Email Attachments', () => {
it('should send email with a single attachment', async () => {
const attachment = {
filename: 'invoice.pdf',
content: Buffer.from('PDF content here').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Your Invoice',
body: 'Please find your invoice attached',
from: 'billing@example.com',
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
const attachments = email.attachments as unknown as Array<{
filename: string;
contentType: string;
}>;
expect(Array.isArray(attachments)).toBe(true);
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
filename: 'invoice.pdf',
contentType: 'application/pdf',
});
});
it('should send email with multiple attachments', async () => {
const attachments = [
{
filename: 'document1.pdf',
content: Buffer.from('PDF 1').toString('base64'),
contentType: 'application/pdf',
},
{
filename: 'image.png',
content: Buffer.from('PNG data').toString('base64'),
contentType: 'image/png',
},
{
filename: 'data.csv',
content: Buffer.from('CSV content').toString('base64'),
contentType: 'text/csv',
},
];
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Multiple Files',
body: 'Here are your files',
from: 'support@example.com',
attachments,
});
expect(email.status).toBe(EmailStatus.PENDING);
const storedAttachments = email.attachments as unknown as Array<{filename: string}>;
expect(storedAttachments).toHaveLength(3);
expect(storedAttachments[0].filename).toBe('document1.pdf');
expect(storedAttachments[1].filename).toBe('image.png');
expect(storedAttachments[2].filename).toBe('data.csv');
});
it('should send email without attachments', async () => {
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'No Attachments',
body: 'Simple email',
from: 'test@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeNull();
});
it('should pass attachments to SES when sending', async () => {
const attachment = {
filename: 'test.txt',
content: Buffer.from('Test content').toString('base64'),
contentType: 'text/plain',
};
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
attachments: [attachment],
});
// Send the email
await EmailService.sendEmail(email.id);
// Verify SES was called with attachments
expect(vi.mocked(sendRawEmail)).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [attachment],
}),
);
});
it('should handle attachments in campaign emails', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
const campaign = await factories.createCampaign({projectId});
const attachment = {
filename: 'newsletter.pdf',
content: Buffer.from('Newsletter content').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
subject: 'Monthly Newsletter',
body: 'See attachment',
from: 'news@example.com',
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
const storedAttachments = email.attachments as unknown as Array<{filename: string}>;
expect(storedAttachments[0].filename).toBe('newsletter.pdf');
});
it('should handle attachments in workflow emails', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
const attachment = {
filename: 'report.pdf',
content: Buffer.from('Report content').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Your Report',
body: 'Report attached',
from: 'reports@example.com',
workflowExecutionId: execution.id,
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
});
});
// ========================================
// ATTACHMENT SCHEMA VALIDATION
// ========================================
describe('Attachment Schema Validation', () => {
it('should validate attachment count limit (max 10)', () => {
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
filename: `file${i}.txt`,
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
}));
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: tooManyAttachments,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.some(e => e.path.includes('attachments'))).toBe(true);
}
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
const largeContent = 'A'.repeat(14000000);
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'huge.txt',
content: largeContent,
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should accept attachments within size limit', () => {
const validContent = Buffer.from('Small file content').toString('base64');
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
from: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'small.txt',
content: validContent,
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(true);
});
it('should reject attachment with missing required fields', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'test.txt',
// Missing content and contentType
},
],
});
expect(result.success).toBe(false);
});
it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: '',
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should reject attachment with filename exceeding 255 chars', () => {
const tooLongFilename = 'a'.repeat(256) + '.pdf';
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: tooLongFilename,
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should accept valid attachment with various content types', () => {
const contentTypes = ['application/pdf', 'image/png', 'image/jpeg', 'text/plain', 'application/zip'];
for (const contentType of contentTypes) {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
from: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'file.ext',
content: Buffer.from('data').toString('base64'),
contentType,
},
],
});
expect(result.success).toBe(true);
}
});
});
});
@@ -0,0 +1,942 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowTriggerType} from '@plunk/db';
import {EventService} from '../EventService';
import {Keys} from '../keys';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
vi.mock('../../database/redis', () => {
const store = new Map<string, {value: string; expiry?: number}>();
return {
redis: {
get: vi.fn(async (key: string) => {
const item = store.get(key);
if (!item) return null;
if (item.expiry && Date.now() > item.expiry) {
store.delete(key);
return null;
}
return item.value;
}),
set: vi.fn(async (key: string, value: string) => {
store.set(key, {value});
return 'OK';
}),
setex: vi.fn(async (key: string, seconds: number, value: string) => {
store.set(key, {value, expiry: Date.now() + seconds * 1000});
return 'OK';
}),
del: vi.fn(async (key: string) => {
store.delete(key);
return 1;
}),
incr: vi.fn(async (key: string) => {
const current = store.get(key);
const newValue = current ? parseInt(current.value) + 1 : 1;
store.set(key, {value: String(newValue)});
return newValue;
}),
expire: vi.fn(async (key: string, seconds: number) => {
const item = store.get(key);
if (!item) return 0;
store.set(key, {...item, expiry: Date.now() + seconds * 1000});
return 1;
}),
clear: () => store.clear(),
},
};
});
describe('EventService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(async () => {
// Clear Redis mock
const {redis} = await import('../../database/redis');
if ('clear' in redis) {
(redis as unknown as {clear: () => void}).clear();
}
});
// ========================================
// EVENT TRACKING
// ========================================
describe('trackEvent', () => {
it('should create an event record', async () => {
const contact = await factories.createContact({projectId});
const event = await EventService.trackEvent(projectId, 'user.signup', contact.id, undefined, {
source: 'web',
plan: 'free',
});
expect(event.projectId).toBe(projectId);
expect(event.contactId).toBe(contact.id);
expect(event.name).toBe('user.signup');
expect(event.data).toEqual({source: 'web', plan: 'free'});
});
it('should track event without contact (project-level event)', async () => {
const event = await EventService.trackEvent(projectId, 'project.created', undefined, undefined, {
plan: 'pro',
});
expect(event.projectId).toBe(projectId);
expect(event.contactId).toBeNull();
expect(event.name).toBe('project.created');
});
it('should track event with email reference', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
const event = await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
userAgent: 'Mozilla/5.0',
});
expect(event.emailId).toBe(email.id);
expect(event.contactId).toBe(contact.id);
});
it('should trigger workflows listening for the event', async () => {
const contact = await factories.createContact({projectId});
// Create workflow triggered by 'purchase.completed' event
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'purchase.completed'},
});
// Add a delay step so workflow doesn't complete immediately
const triggerStep = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: 'TRIGGER'},
});
const delayStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: 'DELAY',
name: 'Wait',
position: {x: 100, y: 0},
config: {amount: 24, unit: 'hours'},
},
});
// Connect steps
await prisma.workflowTransition.create({
data: {
fromStepId: triggerStep!.id,
toStepId: delayStep.id,
},
});
// Track the event
await EventService.trackEvent(projectId, 'purchase.completed', contact.id, undefined, {
amount: 99.99,
product: 'Premium Plan',
});
// Verify workflow execution was created
const executions = await prisma.workflowExecution.findMany({
where: {
workflowId: workflow.id,
contactId: contact.id,
},
});
expect(executions).toHaveLength(1);
// Workflow should be in COMPLETED status since DELAY step completes and has no next step
// (DELAY sets to WAITING then processNextSteps sees no transitions and completes it)
expect([WorkflowExecutionStatus.WAITING, WorkflowExecutionStatus.COMPLETED]).toContain(executions[0].status);
});
it('should NOT trigger disabled workflows', async () => {
const contact = await factories.createContact({projectId});
// Create disabled workflow
const workflow = await factories.createWorkflow({
projectId,
enabled: false, // Disabled
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
await EventService.trackEvent(projectId, 'test.event', contact.id);
// No execution should be created
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id},
});
expect(executions).toHaveLength(0);
});
it('should NOT trigger workflows for different event names', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'user.signup'},
});
// Track different event
await EventService.trackEvent(projectId, 'user.login', contact.id);
// No execution should be created
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id},
});
expect(executions).toHaveLength(0);
});
it('should respect workflow re-entry settings', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: false, // Do not allow re-entry
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'repeat.event'},
});
// First event - should create execution
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
let executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(1);
// Second event - should NOT create execution (re-entry not allowed)
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(1); // Still only 1
});
it('should allow re-entry when enabled', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: true, // Allow re-entry
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'repeat.event'},
});
// First event
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
// Complete first execution
const firstExecution = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow.id, contactId: contact.id},
});
await prisma.workflowExecution.update({
where: {id: firstExecution!.id},
data: {status: WorkflowExecutionStatus.COMPLETED},
});
// Second event - should create new execution
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(2);
});
it('should trigger multiple workflows listening for same event', async () => {
const contact = await factories.createContact({projectId});
const workflow1 = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'shared.event'},
});
const workflow2 = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'shared.event'},
});
await EventService.trackEvent(projectId, 'shared.event', contact.id);
const execution1 = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow1.id},
});
const execution2 = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow2.id},
});
expect(execution1).toBeDefined();
expect(execution2).toBeDefined();
});
});
// ========================================
// EVENT RETRIEVAL
// ========================================
describe('getContactEvents', () => {
it('should get events for a specific contact', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.1', contact1.id);
await EventService.trackEvent(projectId, 'event.2', contact1.id);
await EventService.trackEvent(projectId, 'event.3', contact2.id);
const events = await EventService.getContactEvents(projectId, contact1.id);
expect(events).toHaveLength(2);
expect(events.every(e => e.contactId === contact1.id)).toBe(true);
});
it('should return events in reverse chronological order (newest first)', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'first', contact.id);
await new Promise(resolve => setTimeout(resolve, 10));
await EventService.trackEvent(projectId, 'second', contact.id);
await new Promise(resolve => setTimeout(resolve, 10));
await EventService.trackEvent(projectId, 'third', contact.id);
const events = await EventService.getContactEvents(projectId, contact.id);
expect(events[0].name).toBe('third'); // Newest
expect(events[1].name).toBe('second');
expect(events[2].name).toBe('first'); // Oldest
});
it('should respect limit parameter', async () => {
const contact = await factories.createContact({projectId});
// Create 100 events
for (let i = 0; i < 100; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getContactEvents(projectId, contact.id, 25);
expect(events).toHaveLength(25);
});
it('should default to 50 events limit', async () => {
const contact = await factories.createContact({projectId});
// Create 60 events
for (let i = 0; i < 60; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getContactEvents(projectId, contact.id);
expect(events).toHaveLength(50); // Default limit
});
});
describe('getProjectEvents', () => {
it('should get all events for a project', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.1', contact1.id);
await EventService.trackEvent(projectId, 'event.2', contact2.id);
await EventService.trackEvent(projectId, 'event.3');
const events = await EventService.getProjectEvents(projectId);
expect(events).toHaveLength(3);
expect(events.every(e => e.projectId === projectId)).toBe(true);
});
it('should filter by event name', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.login', contact.id);
await EventService.trackEvent(projectId, 'user.signup', contact.id);
const events = await EventService.getProjectEvents(projectId, 'user.signup');
expect(events).toHaveLength(2);
expect(events.every(e => e.name === 'user.signup')).toBe(true);
});
it('should include contact email in results', async () => {
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
await EventService.trackEvent(projectId, 'test.event', contact.id);
const events = await EventService.getProjectEvents(projectId);
expect(events[0].contact?.email).toBe('test@example.com');
});
it('should respect limit parameter', async () => {
const contact = await factories.createContact({projectId});
for (let i = 0; i < 150; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getProjectEvents(projectId, undefined, 50);
expect(events).toHaveLength(50);
});
});
// ========================================
// EVENT STATISTICS
// ========================================
describe('getEventStats', () => {
it('should return event counts grouped by type', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.login', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
const stats = await EventService.getEventStats(projectId);
expect(stats).toHaveLength(3);
// Should be ordered by count (desc)
expect(stats[0].name).toBe('purchase.completed');
expect(stats[0].count).toBe(3);
expect(stats[1].name).toBe('user.signup');
expect(stats[1].count).toBe(2);
expect(stats[2].name).toBe('user.login');
expect(stats[2].count).toBe(1);
});
it('should filter by date range', async () => {
const contact = await factories.createContact({projectId});
const oldDate = new Date('2024-01-01');
// Create old event directly
await prisma.event.create({
data: {
projectId,
contactId: contact.id,
name: 'old.event',
createdAt: oldDate,
},
});
// Create recent events
await EventService.trackEvent(projectId, 'recent.event', contact.id);
const startDate = new Date('2024-05-01');
const stats = await EventService.getEventStats(projectId, startDate);
expect(stats).toHaveLength(1);
expect(stats[0].name).toBe('recent.event');
});
it('should handle empty result', async () => {
const stats = await EventService.getEventStats(projectId);
expect(stats).toHaveLength(0);
});
});
describe('getUniqueEventNames', () => {
it('should return unique event names ordered by frequency', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.a', contact.id);
await EventService.trackEvent(projectId, 'event.b', contact.id);
await EventService.trackEvent(projectId, 'event.b', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
const names = await EventService.getUniqueEventNames(projectId);
expect(names).toHaveLength(3);
expect(names[0]).toBe('event.c'); // Most frequent
expect(names[1]).toBe('event.b');
expect(names[2]).toBe('event.a'); // Least frequent
});
it('should return empty array when no events exist', async () => {
const names = await EventService.getUniqueEventNames(projectId);
expect(names).toHaveLength(0);
});
});
// ========================================
// WORKFLOW CACHE MANAGEMENT
// ========================================
describe('invalidateWorkflowCache', () => {
it('should invalidate workflow cache for project', async () => {
const {redis} = await import('../../database/redis');
// Set cache
const cacheKey = Keys.Workflow.enabled(projectId);
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
// Verify cache exists
const cached = await redis.get(cacheKey);
expect(cached).toBeTruthy();
// Invalidate
await EventService.invalidateWorkflowCache(projectId);
// Verify cache deleted
const afterInvalidation = await redis.get(cacheKey);
expect(afterInvalidation).toBeNull();
});
it('should not throw error if cache does not exist', async () => {
await expect(EventService.invalidateWorkflowCache(projectId)).resolves.not.toThrow();
});
});
// ========================================
// EDGE CASES
// ========================================
describe('edge cases', () => {
it('should handle events with complex data structures', async () => {
const contact = await factories.createContact({projectId});
const complexData = {
user: {
id: 123,
profile: {
name: 'John Doe',
preferences: ['email', 'sms'],
},
},
metadata: {
source: 'mobile_app',
version: '2.0.1',
},
items: [
{id: 1, name: 'Item 1', price: 19.99},
{id: 2, name: 'Item 2', price: 29.99},
],
};
const event = await EventService.trackEvent(projectId, 'complex.event', contact.id, undefined, complexData);
expect(event.data).toEqual(complexData);
// Verify data persists correctly
const retrieved = await prisma.event.findUnique({
where: {id: event.id},
});
expect(retrieved?.data).toEqual(complexData);
});
it('should handle events with null data', async () => {
const contact = await factories.createContact({projectId});
const event = await EventService.trackEvent(projectId, 'simple.event', contact.id);
expect(event.data).toBeNull();
});
it('should handle event names with special characters', async () => {
const contact = await factories.createContact({projectId});
const eventName = 'user:action@domain.com/path-123';
const event = await EventService.trackEvent(projectId, eventName, contact.id);
expect(event.name).toBe(eventName);
});
it('should not trigger workflows for events without contact when workflow expects contact', async () => {
await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
// Track event without contact
await EventService.trackEvent(projectId, 'test.event');
// No execution should be created (event is not contact-specific)
const executions = await prisma.workflowExecution.findMany({
where: {workflow: {projectId}},
});
expect(executions).toHaveLength(0);
});
});
// ========================================
// FIELD DISCOVERY
// ========================================
describe('getAvailableEventFields', () => {
it('should discover fields from event data', async () => {
const contact = await factories.createContact({projectId});
// Create events with various fields
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Welcome',
from: 'hello@example.com',
openedAt: new Date().toISOString(),
});
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Newsletter',
from: 'news@example.com',
isFirstOpen: true,
opens: 1,
});
const fields = await EventService.getAvailableEventFields(projectId, 'email.opened');
expect(fields).toContain('event.subject');
expect(fields).toContain('event.from');
expect(fields).toContain('event.openedAt');
expect(fields).toContain('event.isFirstOpen');
expect(fields).toContain('event.opens');
expect(fields).toHaveLength(5);
});
it('should filter fields by event name', async () => {
const contact = await factories.createContact({projectId});
// Create email.opened events
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Test',
openedAt: new Date().toISOString(),
});
// Create email.clicked events
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
subject: 'Test',
link: 'https://example.com',
clickedAt: new Date().toISOString(),
});
// Get fields for email.opened only
const openedFields = await EventService.getAvailableEventFields(projectId, 'email.opened');
expect(openedFields).toContain('event.subject');
expect(openedFields).toContain('event.openedAt');
expect(openedFields).not.toContain('event.link');
expect(openedFields).not.toContain('event.clickedAt');
});
it('should return all event fields when no event name specified', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
openedAt: new Date().toISOString(),
});
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
link: 'https://example.com',
});
const allFields = await EventService.getAvailableEventFields(projectId);
expect(allFields).toContain('event.openedAt');
expect(allFields).toContain('event.link');
});
it('should return empty array when no events exist', async () => {
const fields = await EventService.getAvailableEventFields(projectId, 'nonexistent.event');
expect(fields).toHaveLength(0);
});
it('should ignore events with null data', async () => {
const contact = await factories.createContact({projectId});
// Event without data
await EventService.trackEvent(projectId, 'simple.event', contact.id);
// Event with data
await EventService.trackEvent(projectId, 'simple.event', contact.id, undefined, {
field1: 'value1',
});
const fields = await EventService.getAvailableEventFields(projectId, 'simple.event');
expect(fields).toEqual(['event.field1']);
});
it('should handle nested field structures', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'complex.event', contact.id, undefined, {
user: {
name: 'John',
email: 'john@example.com',
},
metadata: {
source: 'web',
},
});
const fields = await EventService.getAvailableEventFields(projectId, 'complex.event');
// Should return top-level keys
expect(fields).toContain('event.user');
expect(fields).toContain('event.metadata');
});
it('should deduplicate fields across multiple events', async () => {
const contact = await factories.createContact({projectId});
// Create 5 events with same fields
for (let i = 0; i < 5; i++) {
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
field1: `value${i}`,
field2: `value${i}`,
});
}
const fields = await EventService.getAvailableEventFields(projectId, 'test.event');
expect(fields).toEqual(['event.field1', 'event.field2']);
});
it('should respect 100 event sample limit for performance', async () => {
const contact = await factories.createContact({projectId});
// Create 150 events with different fields
for (let i = 0; i < 150; i++) {
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
[`field${i}`]: `value${i}`,
});
}
const fields = await EventService.getAvailableEventFields(projectId, 'test.event');
// Should sample only last 100 events (most recent)
// So we should see field50-field149 (100 fields)
expect(fields.length).toBeLessThanOrEqual(100);
});
it('should return fields in alphabetical order', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
zebra: 'last',
apple: 'first',
middle: 'middle',
});
const fields = await EventService.getAvailableEventFields(projectId, 'test.event');
expect(fields).toEqual(['event.apple', 'event.middle', 'event.zebra']);
});
it('should handle email event data structure', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
// Simulate actual email.sent event data
await EventService.trackEvent(projectId, 'email.sent', contact.id, email.id, {
subject: 'Welcome Email',
from: 'hello@example.com',
fromName: 'Example Team',
messageId: 'msg-123',
templateId: 'tpl-456',
campaignId: null,
sourceType: 'TRANSACTIONAL',
sentAt: new Date().toISOString(),
});
const fields = await EventService.getAvailableEventFields(projectId, 'email.sent');
expect(fields).toContain('event.subject');
expect(fields).toContain('event.from');
expect(fields).toContain('event.fromName');
expect(fields).toContain('event.messageId');
expect(fields).toContain('event.templateId');
expect(fields).toContain('event.campaignId');
expect(fields).toContain('event.sourceType');
expect(fields).toContain('event.sentAt');
});
});
describe('Event Data - Persistent vs Non-Persistent', () => {
it('should store all event data (persistent + non-persistent) in event record', async () => {
const contact = await factories.createContact({projectId});
const eventData = {
totalSpent: 599.99, // Persistent
orderId: {value: 'ORD-123', persistent: false}, // Non-persistent
items: {value: [{name: 'Widget', qty: 2}], persistent: false}, // Non-persistent
};
const event = await EventService.trackEvent(projectId, 'purchase', contact.id, undefined, eventData);
// Event should store ALL data for workflow access
expect(event.data).toMatchObject({
totalSpent: 599.99,
orderId: {value: 'ORD-123', persistent: false},
items: {value: [{name: 'Widget', qty: 2}], persistent: false},
});
});
it('should pass all event data to workflow execution context', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'order_placed'},
});
await factories.createWorkflowStep({
workflowId: workflow.id,
type: 'TRIGGER',
name: 'Order Trigger',
position: {x: 0, y: 0},
config: {},
});
const eventData = {
amount: 99.99, // Persistent
confirmationCode: {value: 'CONF-456', persistent: false}, // Non-persistent
};
await EventService.trackEvent(projectId, 'order_placed', contact.id, undefined, eventData);
// Wait for async workflow creation
await new Promise(resolve => setTimeout(resolve, 50));
const execution = await prisma.workflowExecution.findFirst({
where: {
workflowId: workflow.id,
contactId: contact.id,
},
});
// Execution context should have ALL event data
expect(execution).toBeDefined();
expect(execution?.context).toMatchObject({
amount: 99.99,
confirmationCode: {value: 'CONF-456', persistent: false},
});
});
});
// ========================================
// RESERVED EVENT VALIDATION
// ========================================
describe('isReservedEvent', () => {
describe('Email events (email.*)', () => {
it('should identify email.sent as reserved', () => {
expect(EventService.isReservedEvent('email.sent')).toBe(true);
});
it('should identify email.delivery as reserved', () => {
expect(EventService.isReservedEvent('email.delivery')).toBe(true);
});
it('should identify email.open as reserved', () => {
expect(EventService.isReservedEvent('email.open')).toBe(true);
});
it('should identify email.click as reserved', () => {
expect(EventService.isReservedEvent('email.click')).toBe(true);
});
it('should identify email.bounce as reserved', () => {
expect(EventService.isReservedEvent('email.bounce')).toBe(true);
});
it('should identify email.complaint as reserved', () => {
expect(EventService.isReservedEvent('email.complaint')).toBe(true);
});
it('should identify any email.* pattern as reserved', () => {
expect(EventService.isReservedEvent('email.custom')).toBe(true);
expect(EventService.isReservedEvent('email.anything')).toBe(true);
});
});
describe('Contact events', () => {
it('should identify contact.subscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.subscribed')).toBe(true);
});
it('should identify contact.unsubscribed as reserved', () => {
expect(EventService.isReservedEvent('contact.unsubscribed')).toBe(true);
});
it('should not identify other contact.* events as reserved', () => {
expect(EventService.isReservedEvent('contact.created')).toBe(false);
expect(EventService.isReservedEvent('contact.updated')).toBe(false);
});
});
describe('Segment events (segment.*.entry, segment.*.exit)', () => {
it('should identify segment.*.entry as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.entry')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.entry')).toBe(true);
});
it('should identify segment.*.exit as reserved', () => {
expect(EventService.isReservedEvent('segment.vip-users.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.premium.exit')).toBe(true);
expect(EventService.isReservedEvent('segment.active-subscribers.exit')).toBe(true);
});
it('should not identify other segment.* events as reserved', () => {
expect(EventService.isReservedEvent('segment.created')).toBe(false);
expect(EventService.isReservedEvent('segment.vip-users.updated')).toBe(false);
expect(EventService.isReservedEvent('segment.premium')).toBe(false);
});
});
describe('Non-reserved events', () => {
it('should not identify custom user events as reserved', () => {
expect(EventService.isReservedEvent('user.signup')).toBe(false);
expect(EventService.isReservedEvent('purchase.completed')).toBe(false);
expect(EventService.isReservedEvent('order.placed')).toBe(false);
expect(EventService.isReservedEvent('custom.event')).toBe(false);
});
it('should not identify events with similar prefixes as reserved', () => {
expect(EventService.isReservedEvent('emails.sent')).toBe(false);
expect(EventService.isReservedEvent('contacts.subscribed')).toBe(false);
expect(EventService.isReservedEvent('segments.entry')).toBe(false);
});
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,712 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {type SegmentFilter, SegmentService} from '../SegmentService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Type for intentionally invalid filter input (used for testing validation)
type InvalidFilterInput = Partial<SegmentFilter>;
describe('SegmentService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Segment Filtering', () => {
it('should filter contacts by subscribed status', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const _unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Subscribed Users',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id);
});
it('should filter contacts by custom data fields', async () => {
const proUser = await factories.createContact({
projectId,
data: {plan: 'pro', tier: 'premium'},
});
const _freeUser = await factories.createContact({
projectId,
data: {plan: 'free', tier: 'basic'},
});
const segment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id);
});
it('should filter contacts with multiple conditions', async () => {
const target = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro', active: true},
});
const _notSubscribed = await factories.createContact({
projectId,
subscribed: false,
data: {plan: 'pro', active: true},
});
const _notPro = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free', active: true},
});
const segment = await factories.createSegment(projectId, {
name: 'Active Pro Subscribers',
filters: [
{field: 'subscribed', operator: 'equals', value: true},
{field: 'data.plan', operator: 'equals', value: 'pro'},
{field: 'data.active', operator: 'equals', value: true},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id);
});
it('should support notEquals operator', async () => {
const pro = await factories.createContact({
projectId,
data: {plan: 'pro'},
});
const _free = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Non-Free Users',
filters: [{field: 'data.plan', operator: 'notEquals', value: 'free'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id);
});
it('should support contains operator for strings', async () => {
const match = await factories.createContact({
projectId,
email: 'user@company.com',
});
const _noMatch = await factories.createContact({
projectId,
email: 'user@example.com',
});
const segment = await factories.createSegment(projectId, {
name: 'Company Emails',
filters: [{field: 'email', operator: 'contains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should support exists operator for custom fields', async () => {
const withField = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
await factories.createContact({
projectId,
data: {name: 'John'},
});
const segment = await factories.createSegment(projectId, {
name: 'Has Company',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
});
it('should handle empty segments', async () => {
await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Enterprise Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'enterprise'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('Segment Membership', () => {
it('should return correct member count', async () => {
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2);
});
it('should support pagination', async () => {
// Create 25 contacts
for (let i = 0; i < 25; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
expect(page1.contacts).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
expect(page2.contacts).toHaveLength(10);
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
expect(page3.contacts).toHaveLength(5);
});
});
describe('Segment Management', () => {
it('should create segment with filters', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
expect(segment.name).toBe('VIP Customers');
expect(segment.projectId).toBe(projectId);
// Segments now use condition object with groups containing filters
expect(segment.condition).toBeDefined();
expect(typeof segment.condition).toBe('object');
});
it('should list all segments for a project', async () => {
await factories.createSegment(projectId, {name: 'Segment 1'});
await factories.createSegment(projectId, {name: 'Segment 2'});
await factories.createSegment(projectId, {name: 'Segment 3'});
const segments = await SegmentService.list(projectId);
expect(segments).toHaveLength(3);
});
it('should get specific segment by id', async () => {
const segment = await factories.createSegment(projectId, {
name: 'Test Segment',
});
const retrieved = await SegmentService.get(projectId, segment.id);
expect(retrieved.id).toBe(segment.id);
expect(retrieved.name).toBe('Test Segment');
});
it('should throw error when segment not found', async () => {
await expect(SegmentService.get(projectId, 'non-existent-id')).rejects.toThrow('Segment not found');
});
});
describe('Dynamic Segment Updates', () => {
it('should reflect in segment when contact data changes', async () => {
const contact = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const proSegment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
// Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0);
// Update contact to pro plan
await prisma.contact.update({
where: {id: contact.id},
data: {data: {plan: 'pro'}},
});
// Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id);
});
it('should be removed from segment when criteria no longer met', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
// Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
// Unsubscribe contact
await prisma.contact.update({
where: {id: contact.id},
data: {subscribed: false},
});
// Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
});
describe('Delete Protection for Active Campaigns', () => {
it('should BLOCK deleting segment used in DRAFT campaigns', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should BLOCK deleting segment used in SCHEDULED campaigns', async () => {
const segment = await factories.createSegment(projectId);
await factories.createScheduledCampaign({
projectId,
segmentId: segment.id,
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should ALLOW deleting segment if all campaigns are SENT', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SENT',
});
await SegmentService.delete(projectId, segment.id);
const deleted = await prisma.segment.findUnique({
where: {id: segment.id},
});
expect(deleted).toBeNull();
});
it('should show count of blocking campaigns in error message', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SCHEDULED',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(/2.*active campaign/i);
});
});
describe('Filter Validation', () => {
it('should REJECT empty filters array', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [{filters: []}],
},
}),
).rejects.toThrow(/at least one filter/i);
});
it('should REJECT filter without field', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
// Intentionally missing field, cast to bypass compile-time validation
operator: 'equals',
value: 'test',
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/field is required/i);
});
it('should REJECT invalid operators', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'email',
// Intentionally invalid operator
operator: 'DROP TABLE contacts;',
value: 'test',
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/invalid operator/i);
});
it('should REJECT operators that need values without values', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'email',
operator: 'equals',
// Value intentionally omitted
} as InvalidFilterInput as SegmentFilter,
],
},
],
},
}),
).rejects.toThrow(/requires a value/i);
});
it('should ACCEPT valid filters', async () => {
const segment = await SegmentService.create(projectId, {
name: 'Valid Segment',
condition: {
logic: 'AND',
groups: [
{
filters: [
{
field: 'subscribed',
operator: 'equals',
value: true,
},
],
},
],
},
});
expect(segment.id).toBeDefined();
expect(segment.name).toBe('Valid Segment');
});
});
describe('Operator behavior and edge cases', () => {
it('should support notContains operator for email strings', async () => {
const match = await factories.createContact({
projectId,
email: 'user@company.com',
});
const other = await factories.createContact({
projectId,
email: 'user@example.com',
});
const segment = await factories.createSegment(projectId, {
name: 'Non-company Emails',
filters: [{field: 'email', operator: 'notContains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
});
it('should support case-insensitive equals/contains for email strings', async () => {
const lower = await factories.createContact({
projectId,
email: 'user@company.com',
});
const upper = await factories.createContact({
projectId,
email: 'USER@COMPANY.COM',
});
const equalsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive equals',
filters: [{field: 'email', operator: 'equals', value: 'USER@COMPANY.COM'}],
});
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
const equalsIds = equalsResult.contacts.map(c => c.id);
expect(equalsIds).toContain(lower.id);
expect(equalsIds).toContain(upper.id);
const containsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive contains',
filters: [{field: 'email', operator: 'contains', value: 'COMPANY.COM'}],
});
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
const containsIds = containsResult.contacts.map(c => c.id);
expect(containsIds).toContain(lower.id);
expect(containsIds).toContain(upper.id);
});
it('should support notEquals for boolean subscribed field', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Not subscribed users',
filters: [{field: 'subscribed', operator: 'notEquals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id);
});
it('should support notContains and notEquals for JSON data fields', async () => {
const acme = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const other = await factories.createContact({
projectId,
data: {company: 'Other Corp'},
});
const notContainsSegment = await factories.createSegment(projectId, {
name: 'Company not containing "Acme"',
filters: [{field: 'data.company', operator: 'notContains', value: 'Acme'}],
});
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
const notContainsIds = notContainsResult.contacts.map(c => c.id);
expect(notContainsIds).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id);
const notEqualsSegment = await factories.createSegment(projectId, {
name: 'Company not equal to "Acme Inc"',
filters: [{field: 'data.company', operator: 'notEquals', value: 'Acme Inc'}],
});
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
expect(notEqualsIds).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id);
});
it('should support exists and notExists for JSON data fields', async () => {
const withCompany = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const withNullCompany = await factories.createContact({
projectId,
data: {company: null},
});
const existsSegment = await factories.createSegment(projectId, {
name: 'Has company (non-null)',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
const existsIds = new Set(existsResult.contacts.map(c => c.id));
expect(existsIds.has(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false);
const notExistsSegment = await factories.createSegment(projectId, {
name: 'No company (null)',
filters: [{field: 'data.company', operator: 'notExists', value: true}],
});
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
expect(notExistsIds.has(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
});
it('should support numeric comparison operators on JSON data fields', async () => {
const low = await factories.createContact({
projectId,
data: {score: 10},
});
const mid = await factories.createContact({
projectId,
data: {score: 50},
});
const high = await factories.createContact({
projectId,
data: {score: 100},
});
const greaterThanSegment = await factories.createSegment(projectId, {
name: 'Score > 10',
filters: [{field: 'data.score', operator: 'greaterThan', value: 10}],
});
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
const gtIds = greaterThanResult.contacts.map(c => c.id);
expect(gtIds).toContain(mid.id);
expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id);
const lessThanOrEqualSegment = await factories.createSegment(projectId, {
name: 'Score <= 50',
filters: [{field: 'data.score', operator: 'lessThanOrEqual', value: 50}],
});
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
expect(lteIds).toContain(low.id);
expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id);
});
it('should support date comparison operators on createdAt field', async () => {
const older = await factories.createContact({projectId});
// Ensure a small delay so createdAt differs
await new Promise(resolve => setTimeout(resolve, 10));
const newer = await factories.createContact({projectId});
const gtSegment = await factories.createSegment(projectId, {
name: 'Created after first',
filters: [{field: 'createdAt', operator: 'greaterThan', value: older.createdAt.toISOString()}],
});
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
const gtIds = gtResult.contacts.map(c => c.id);
expect(gtIds).toContain(newer.id);
expect(gtIds).not.toContain(older.id);
const lteSegment = await factories.createSegment(projectId, {
name: 'Created on or before second',
filters: [{field: 'createdAt', operator: 'lessThanOrEqual', value: newer.createdAt.toISOString()}],
});
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
expect(lteIds).toContain(older.id);
expect(lteIds).toContain(newer.id);
});
it('should support within operator for recent contacts', async () => {
const recent = await factories.createContact({projectId});
const segment = await factories.createSegment(projectId, {
name: 'Created within last day',
filters: [
{
field: 'createdAt',
operator: 'within',
value: 1,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(recent.id);
});
});
});
@@ -0,0 +1,570 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('TemplateService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// CRUD OPERATIONS
// ========================================
describe('create', () => {
it('should create a template with all fields', async () => {
const template = await TemplateService.create(projectId, {
name: 'Welcome Email',
description: 'Sent to new users',
subject: 'Welcome to {{company}}!',
body: '<h1>Hello {{firstName}}</h1>',
from: 'hello@example.com',
fromName: 'Company Team',
replyTo: 'support@example.com',
type: TemplateType.TRANSACTIONAL,
});
expect(template.name).toBe('Welcome Email');
expect(template.description).toBe('Sent to new users');
expect(template.subject).toBe('Welcome to {{company}}!');
expect(template.body).toBe('<h1>Hello {{firstName}}</h1>');
expect(template.from).toBe('hello@example.com');
expect(template.fromName).toBe('Company Team');
expect(template.replyTo).toBe('support@example.com');
expect(template.type).toBe(TemplateType.TRANSACTIONAL);
expect(template.projectId).toBe(projectId);
});
it('should create template with minimal required fields', async () => {
const template = await TemplateService.create(projectId, {
name: 'Basic Template',
subject: 'Test',
body: 'Test body',
from: 'test@example.com',
});
expect(template.name).toBe('Basic Template');
expect(template.type).toBe(TemplateType.MARKETING); // Default type
expect(template.description).toBeNull();
expect(template.fromName).toBeNull();
expect(template.replyTo).toBeNull();
});
it('should default to MARKETING type when not specified', async () => {
const template = await TemplateService.create(projectId, {
name: 'Newsletter',
subject: 'Monthly Update',
body: 'Content',
from: 'news@example.com',
});
expect(template.type).toBe(TemplateType.MARKETING);
});
});
describe('get', () => {
it('should retrieve a template by ID', async () => {
const created = await factories.createTemplate({
projectId,
name: 'Test Template',
});
const retrieved = await TemplateService.get(projectId, created.id);
expect(retrieved.id).toBe(created.id);
expect(retrieved.name).toBe('Test Template');
});
it('should throw 404 when template not found', async () => {
await expect(TemplateService.get(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
it('should throw 404 when template belongs to different project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({
projectId: otherProject.id,
});
await expect(TemplateService.get(projectId, template.id)).rejects.toThrow('Template not found');
});
});
describe('list', () => {
it('should list templates with pagination', async () => {
// Create 25 templates
for (let i = 0; i < 25; i++) {
await factories.createTemplate({
projectId,
name: `Template ${i}`,
});
}
const page1 = await TemplateService.list(projectId, 1, 10);
expect(page1.templates).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10);
expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5);
expect(page3.page).toBe(3);
});
it('should filter templates by search query (name)', async () => {
await factories.createTemplate({projectId, name: 'Welcome Email'});
await factories.createTemplate({projectId, name: 'Password Reset'});
await factories.createTemplate({projectId, name: 'Welcome Message'});
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should filter templates by search query (description)', async () => {
await prisma.template.create({
data: {
projectId,
name: 'Template 1',
description: 'For new users',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
await prisma.template.create({
data: {
projectId,
name: 'Template 2',
description: 'For existing customers',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
await prisma.template.create({
data: {
projectId,
name: 'Template 3',
description: 'For new subscribers',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2);
expect(result.templates.map(t => t.description)).toEqual(
expect.arrayContaining([expect.stringContaining('new')]),
);
});
it('should filter templates by search query (subject)', async () => {
await factories.createTemplate({
projectId,
subject: 'Welcome to our platform',
});
await factories.createTemplate({
projectId,
subject: 'Reset your password',
});
await factories.createTemplate({
projectId,
subject: 'Welcome back!',
});
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
});
it('should filter templates by type', async () => {
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2);
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1);
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
});
it('should combine search and type filters', async () => {
await factories.createTemplate({
projectId,
name: 'Welcome Email',
type: TemplateType.MARKETING,
});
await factories.createTemplate({
projectId,
name: 'Welcome SMS',
type: TemplateType.TRANSACTIONAL,
});
await factories.createTemplate({
projectId,
name: 'Newsletter',
type: TemplateType.MARKETING,
});
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1);
expect(result.templates[0].name).toBe('Welcome Email');
});
it('should return templates ordered by creation date (newest first)', async () => {
const template1 = await factories.createTemplate({projectId, name: 'First'});
// Small delay to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 10));
const template2 = await factories.createTemplate({projectId, name: 'Second'});
await new Promise(resolve => setTimeout(resolve, 10));
const template3 = await factories.createTemplate({projectId, name: 'Third'});
const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest
});
it('should only return templates for the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
await factories.createTemplate({projectId});
await factories.createTemplate({projectId});
await factories.createTemplate({projectId: otherProject.id});
const result = await TemplateService.list(projectId);
expect(result.total).toBe(2);
});
});
describe('update', () => {
it('should update template name', async () => {
const template = await factories.createTemplate({
projectId,
name: 'Old Name',
});
const updated = await TemplateService.update(projectId, template.id, {
name: 'New Name',
});
expect(updated.name).toBe('New Name');
});
it('should update template body and subject', async () => {
const template = await factories.createTemplate({projectId});
const updated = await TemplateService.update(projectId, template.id, {
subject: 'New Subject',
body: '<p>New body content</p>',
});
expect(updated.subject).toBe('New Subject');
expect(updated.body).toBe('<p>New body content</p>');
});
it('should update template type', async () => {
const template = await factories.createTemplate({
projectId,
type: TemplateType.MARKETING,
});
const updated = await TemplateService.update(projectId, template.id, {
type: TemplateType.TRANSACTIONAL,
});
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
});
it('should update email fields (from, fromName, replyTo)', async () => {
const template = await factories.createTemplate({projectId});
const updated = await TemplateService.update(projectId, template.id, {
from: 'new@example.com',
fromName: 'New Name',
replyTo: 'reply@example.com',
});
expect(updated.from).toBe('new@example.com');
expect(updated.fromName).toBe('New Name');
expect(updated.replyTo).toBe('reply@example.com');
});
it('should throw 404 when updating non-existent template', async () => {
await expect(TemplateService.update(projectId, 'non-existent-id', {name: 'New Name'})).rejects.toThrow(
'Template not found',
);
});
it('should throw 404 when updating template from different project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({projectId: otherProject.id});
await expect(TemplateService.update(projectId, template.id, {name: 'New Name'})).rejects.toThrow(
'Template not found',
);
});
});
describe('delete', () => {
it('should delete a template', async () => {
const template = await factories.createTemplate({projectId});
await TemplateService.delete(projectId, template.id);
const deleted = await prisma.template.findUnique({
where: {id: template.id},
});
expect(deleted).toBeNull();
});
it('should throw 404 when deleting non-existent template', async () => {
await expect(TemplateService.delete(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
it('should BLOCK deleting template used in workflow steps', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
await expect(TemplateService.delete(projectId, template.id)).rejects.toThrow(/currently used in workflow steps/i);
});
it('should ALLOW deleting template that was used but no longer in workflows', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
// Remove template from workflow step
await prisma.workflowStep.update({
where: {id: step.id},
data: {templateId: null},
});
// Should now be deletable
await TemplateService.delete(projectId, template.id);
const deleted = await prisma.template.findUnique({
where: {id: template.id},
});
expect(deleted).toBeNull();
});
});
describe('duplicate', () => {
it('should duplicate a template with (Copy) suffix', async () => {
const original = await factories.createTemplate({
projectId,
name: 'Original Template',
description: 'Original description',
subject: 'Original subject',
body: 'Original body',
from: 'original@example.com',
fromName: 'Original Name',
replyTo: 'reply@example.com',
type: TemplateType.TRANSACTIONAL,
});
const duplicate = await TemplateService.duplicate(projectId, original.id);
expect(duplicate.id).not.toBe(original.id);
expect(duplicate.name).toBe('Original Template (Copy)');
expect(duplicate.description).toBe(original.description);
expect(duplicate.subject).toBe(original.subject);
expect(duplicate.body).toBe(original.body);
expect(duplicate.from).toBe(original.from);
expect(duplicate.fromName).toBe(original.fromName);
expect(duplicate.replyTo).toBe(original.replyTo);
expect(duplicate.type).toBe(original.type);
expect(duplicate.projectId).toBe(projectId);
});
it('should handle duplicating template with null optional fields', async () => {
const original = await prisma.template.create({
data: {
projectId,
name: 'Minimal Template',
subject: 'Subject',
body: 'Body',
from: 'from@example.com',
description: null,
fromName: null,
replyTo: null,
},
});
const duplicate = await TemplateService.duplicate(projectId, original.id);
expect(duplicate.name).toBe('Minimal Template (Copy)');
expect(duplicate.description).toBeNull();
expect(duplicate.fromName).toBeNull();
expect(duplicate.replyTo).toBeNull();
});
it('should throw 404 when duplicating non-existent template', async () => {
await expect(TemplateService.duplicate(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
});
// ========================================
// TEMPLATE USAGE TRACKING
// ========================================
describe('getUsage', () => {
it('should return usage statistics for template', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
// Create workflow steps using this template
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
// Create emails sent using this template
const contact = await factories.createContact({projectId});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
const usage = await TemplateService.getUsage(projectId, template.id);
expect(usage.workflowSteps).toBe(2);
expect(usage.emailsSent).toBe(3);
});
it('should return zero usage for unused template', async () => {
const template = await factories.createTemplate({projectId});
const usage = await TemplateService.getUsage(projectId, template.id);
expect(usage.workflowSteps).toBe(0);
expect(usage.emailsSent).toBe(0);
});
it('should only count usage within the project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({projectId});
// Create workflow steps in different project (shouldn't count)
const otherWorkflow = await factories.createWorkflow({projectId: otherProject.id});
await factories.createWorkflowStep({
workflowId: otherWorkflow.id,
templateId: template.id, // Using same template ID (cross-project reference)
type: 'SEND_EMAIL',
});
const usage = await TemplateService.getUsage(projectId, template.id);
// Should not count workflow step from other project
expect(usage.workflowSteps).toBe(0);
});
it('should throw 404 when getting usage for non-existent template', async () => {
await expect(TemplateService.getUsage(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
});
// ========================================
// EDGE CASES & DATA INTEGRITY
// ========================================
describe('edge cases', () => {
it('should handle templates with HTML content', async () => {
const html = `
<!DOCTYPE html>
<html>
<body>
<h1>Hello {{firstName}}</h1>
<p>Welcome to {{company}}</p>
</body>
</html>
`;
const template = await TemplateService.create(projectId, {
name: 'HTML Template',
subject: 'Welcome',
body: html,
from: 'test@example.com',
});
expect(template.body).toBe(html);
const retrieved = await TemplateService.get(projectId, template.id);
expect(retrieved.body).toBe(html);
});
it('should handle templates with variable placeholders', async () => {
const body = 'Hello {{firstName}} {{lastName}}, your code is {{verificationCode}}';
const subject = 'Welcome {{firstName}} - {{company}}';
const template = await TemplateService.create(projectId, {
name: 'Variables Test',
subject,
body,
from: 'test@example.com',
});
expect(template.subject).toBe(subject);
expect(template.body).toBe(body);
});
it('should handle templates with special characters', async () => {
const template = await TemplateService.create(projectId, {
name: 'Special Chars: @#$%^&*()',
subject: 'Émojis 🎉 & Spëcial Çhars',
body: '<p>Price: $100 • Discount: 20%</p>',
from: 'test@example.com',
});
expect(template.name).toBe('Special Chars: @#$%^&*()');
expect(template.subject).toContain('🎉');
expect(template.body).toContain('$100');
});
it('should handle very long template content', async () => {
const longBody = '<p>' + 'Lorem ipsum '.repeat(1000) + '</p>';
const template = await TemplateService.create(projectId, {
name: 'Long Template',
subject: 'Test',
body: longBody,
from: 'test@example.com',
});
expect(template.body.length).toBeGreaterThan(10000);
});
});
});

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