Compare commits

...
159 Commits
Author SHA1 Message Date
Dries Augustyns d75aed81e1 Merge pull request #295 from useplunk/release-please--branches--next--components--plunk 2026-03-05 11:58:38 +01:00
github-actions[bot] 0cbaa8fc66 chore(next): release 0.7.0 2026-03-05 10:27:05 +00:00
Dries Augustyns 0686b3a3f2 Merge pull request #307 from useplunk/dependabot/npm_and_yarn/tar-7.5.10
chore(deps): bump tar from 7.5.9 to 7.5.10
2026-03-05 11:26:52 +01:00
Dries Augustyns 9b15b93b96 Merge pull request #281 from 5h0ov/multipart/related-support-embed-image
feat(api): support inline images in emails using Content-ID
2026-03-05 11:26:41 +01:00
dependabot[bot] 8f2510f5ec chore(deps): bump tar from 7.5.9 to 7.5.10
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.9 to 7.5.10.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.9...v7.5.10)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-05 10:03:03 +00:00
Dries Augustyns e196d798c5 Merge pull request #293 from useplunk/dependabot/npm_and_yarn/tar-7.5.9 2026-03-05 10:18:30 +01:00
Dries Augustyns 777a12ec89 Merge pull request #296 from useplunk/dependabot/npm_and_yarn/ajv-6.14.0 2026-03-05 10:18:21 +01:00
Dries Augustyns 6c6af775bc Merge pull request #301 from useplunk/dependabot/npm_and_yarn/rollup-4.59.0 2026-03-05 10:18:12 +01:00
Dries Augustyns 4f8c9f029d Merge pull request #305 from useplunk/dependabot/npm_and_yarn/mailparser-3.9.3 2026-03-05 10:18:03 +01:00
Dries Augustyns 8f72994b46 Merge pull request #306 from useplunk/dependabot/npm_and_yarn/multer-2.1.1 2026-03-05 10:17:43 +01:00
dependabot[bot] d162f255c2 chore(deps): bump multer from 2.0.2 to 2.1.1
Bumps [multer](https://github.com/expressjs/multer) from 2.0.2 to 2.1.1.
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.0.2...v2.1.1)

---
updated-dependencies:
- dependency-name: multer
  dependency-version: 2.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-05 07:56:38 +00:00
dependabot[bot] c8e252fefd chore(deps): bump mailparser from 3.9.1 to 3.9.3
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.9.1 to 3.9.3.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.9.1...v3.9.3)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.9.3
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-04 22:23:43 +00:00
Dries Augustyns b8f1ad9ab5 fix: Verify SNS URL before sending fetch request 2026-03-03 08:02:36 +01:00
dependabot[bot] 708ab506f1 chore(deps): bump rollup from 4.53.3 to 4.59.0
Bumps [rollup](https://github.com/rollup/rollup) from 4.53.3 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.53.3...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-28 21:10:59 +00:00
Dries Augustyns ec1f4c9374 fix: Support any locale on creation 2026-02-26 20:57:14 +01:00
Dries Augustyns 5528288c7b Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-23 21:45:25 +01:00
Dries Augustyns 7bd098bdd0 fix: Add support for STATIC segment type in CampaignService 2026-02-23 21:45:17 +01:00
Dries Augustyns cbea263a91 Merge pull request #298 from josephsellers/fix/cookie-domain-local-tld 2026-02-23 14:17:36 +01:00
Dries Augustyns e8a247fe12 docs: Static segments 2026-02-23 14:03:05 +01:00
Dries Augustyns 4b51e386e3 feat: Static segments 2026-02-23 13:57:32 +01:00
josephsellers 59aa7845ba fix: correct cookie domain for .local TLD hostnames
getCookieDomain() returns '.localhost' for hostnames ending in '.local'
(e.g., app.plunk.local), causing the browser to reject the cookie
because the domain doesn't match the request origin.

Split the .localhost and .local cases so that .local hostnames return
the actual base domain (e.g., .plunk.local).
2026-02-22 16:01:25 +00:00
dependabot[bot] 5600c49bb6 chore(deps): bump ajv from 6.12.6 to 6.14.0
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-22 13:46:46 +00:00
Dries Augustyns 21af8fe05e fix: Correctly set domain status on manual verify 2026-02-21 08:41:24 +01:00
Dries Augustyns 2c4d95e604 fix: Do not unsubscribe existing contacts 2026-02-20 16:58:17 +01:00
Dries Augustyns 64bd094b47 feat: Sort projects alphabetically in the dashboard and fix layout 2026-02-20 16:01:57 +01:00
dependabot[bot] 53b33bbd36 chore(deps): bump tar from 7.5.7 to 7.5.9
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.7 to 7.5.9.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.7...v7.5.9)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-19 19:00:34 +00:00
Dries Augustyns 5fa9b9dda5 Merge pull request #291 from useplunk/release-please--branches--next--components--plunk
chore(next): release 0.6.0
2026-02-19 19:57:50 +01:00
Dries Augustyns 4bc5424815 Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-19 19:51:28 +01:00
Dries Augustyns 573e2e8449 security: Enhance job status retrieval with project authorization 2026-02-19 19:51:22 +01:00
github-actions[bot] 00ae942c3c chore(next): release 0.6.0 2026-02-19 13:15:08 +00:00
Dries Augustyns 9c69c7a36e Merge pull request #292 from RyanCasas/patch-1
feat(i18n): Add Spanish language
2026-02-19 14:14:47 +01:00
Ryan Casas 9568b7d345 Add Spanish translations to i18n index 2026-02-19 12:16:40 +01:00
Ryan Casas 6bba9b6199 Add Spanish localization for email preferences 2026-02-19 12:10:29 +01:00
Ryan Casas 2f55d32485 Add Spanish (Spain) language support 2026-02-19 12:05:07 +01:00
Dries Augustyns eec37ecb31 feat: Ability to change workflow trigger 2026-02-18 20:26:41 +01:00
Dries Augustyns f947ee6f22 feat: Add dedicated received type 2026-02-18 16:24:55 +01:00
Dries Augustyns 3330d83d08 feat: Add billing for inbound 2026-02-18 15:38:41 +01:00
Dries Augustyns de7ed84fcf feat: Add billing for inbound 2026-02-18 15:23:31 +01:00
Dries Augustyns e639c72e8b fix: Enhance email bounce notification with latest bounce details 2026-02-18 10:20:58 +01:00
Dries Augustyns 43ea660d52 chore: Clean up linting 2026-02-18 10:08:23 +01:00
Dries Augustyns 1f85d448e2 chore: Updated terms, privacy and dpa 2026-02-18 10:00:54 +01:00
Dries Augustyns e7f168c373 Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-18 08:34:55 +01:00
Dries Augustyns b42698616a docs: Add receiving emails functionality and update DNS records documentation 2026-02-18 08:34:49 +01:00
Dries Augustyns a00473314f Merge pull request #255 from useplunk/release-please--branches--next--components--plunk
chore(next): release 0.5.0
2026-02-17 19:16:13 +01:00
github-actions[bot] 2dabcb2419 chore(next): release 0.5.0 2026-02-17 18:09:45 +00:00
Dries Augustyns d9ae171cc7 chore: fix linting errors 2026-02-17 19:09:01 +01:00
Dries Augustyns e4e334c77a fix: Center "Add Step" nodes below parent nodes and update positions on drag 2026-02-17 19:06:22 +01:00
Dries Augustyns 78d3d224af feat: Add support for custom email recipients in workflow steps 2026-02-17 18:57:15 +01:00
Dries Augustyns f9b1354460 fix: Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings 2026-02-17 18:19:58 +01:00
Dries Augustyns 95e5ec8485 Merge pull request #290 from useplunk/inbound
Inbound
2026-02-17 16:56:29 +01:00
Dries Augustyns d050b55baa feat: Add inbound handling 2026-02-17 16:52:56 +01:00
Dries Augustyns 7964563b62 feat: Add advanced DNS configuration 2026-02-17 16:33:34 +01:00
Dries Augustyns e01dc4066c Merge pull request #289 from useplunk/inbound
feat: Add initial handling in webhook for inbound
2026-02-17 16:07:52 +01:00
Dries Augustyns c286f49097 feat: Add initial handling in webhook for inbound 2026-02-17 16:03:50 +01:00
Dries Augustyns 4c81d9ec04 feat: add documentation link and redirect to WIKI_URI 2026-02-17 10:30:17 +01:00
Dries Augustyns a928666dfc fix: Update contact subscription logic for upsert operations 2026-02-17 10:25:01 +01:00
Shuvadipta Das 64ba19e589 fix(test): consolidate SES MIME boundary tests into EmailService.test.ts 2026-02-16 18:55:03 +05:30
Shuvadipta Das 3a42012ac7 fix: removed "any" data type from SESService.test.ts 2026-02-16 18:47:42 +05:30
Shuvadipta Das 08e5c0d930 fix: contentId header injection fixed and separte unit test SESService added to verify MIME boundaries 2026-02-16 18:27:32 +05:30
Shuvadipta Das 18788ac1ca Merge branch 'next' of https://github.com/5h0ov/plunk into multipart/related-support-embed-image 2026-02-16 18:27:07 +05:30
Dries Augustyns d55dda3127 feat: add minimum thresholds for bounce and complaint rates 2026-02-16 11:03:54 +01:00
Dries Augustyns d637147f25 Merge pull request #284 from useplunk/dependabot/npm_and_yarn/webpack-5.105.0
chore(deps): bump webpack from 5.103.0 to 5.105.0
2026-02-16 10:20:22 +01:00
Dries Augustyns c1ed23c967 Merge pull request #286 from useplunk/dependabot/npm_and_yarn/markdown-it-14.1.1
chore(deps): bump markdown-it from 14.1.0 to 14.1.1
2026-02-16 10:20:08 +01:00
Dries Augustyns 46001619e8 Merge pull request #287 from useplunk/dependabot/npm_and_yarn/qs-6.14.2
chore(deps): bump qs from 6.14.1 to 6.14.2
2026-02-16 10:19:52 +01:00
Dries Augustyns 7a6df7760b Merge pull request #288 from bpotmalnik/feat/add-polish
feat(i18n): add Polish locale translations
2026-02-16 10:00:38 +01:00
Bart Potmalnik d2d779c68f feat(i18n): add Polish locale translations
Add polish translations

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-15 00:25:59 +00:00
Marcel Cruz c2afb2dfab feat(i18n): Add Portuguese translations 2026-01-15 00:32:37 +01:00
Dries Augustyns 0d1ed0ec67 Merge pull request #263 from 5h0ov/new-olderthan-segmentfilter 2026-01-13 10:30:56 +01:00
Dries Augustyns 99526ebfc2 chore: Rename within field label to align with new older than 2026-01-13 10:25:10 +01:00
Dries Augustyns e6baace00d feat: Remove domain from AWS if no longer in use by other projects 2026-01-13 10:19:54 +01:00
Dries Augustyns 743155b06a chore: Add missing variables to turbo.json 2026-01-12 20:15:36 +01:00
Dries Augustyns 0a67a8278f feat: Add additional checks for website, NS records and personal emails 2026-01-12 20:02:08 +01:00
Dries Augustyns 26800a8553 fix: Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation 2026-01-12 18:49:30 +01:00
Dries Augustyns 93165644af feat: Ability to disable signups and disable email verification for self-hosters 2026-01-12 15:20:14 +01:00
Dries Augustyns 4db1ccc3fc fix: Refactor Redis keys to prevent multiple messages on concurrent requests 2026-01-12 08:33:51 +01:00
Dries Augustyns 06c4d644b7 Merge pull request #262 from 5h0ov/development-enhancements 2026-01-12 08:07:47 +01:00
Shuvadipta Das b5fa21a57c feat: add "olderThan" segment filter operator
- Added `olderThan` and `triggeredOlderThan` operators to types and schemas
- Implemented backend logic for `olderThan` operator in SegmentService
- Updated SegmentFilterBuilder and shared index/types files to support new time-based operators
2026-01-12 11:10:53 +05:30
Shuvadipta Das ddbe5237fe fix: added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api 2026-01-12 11:07:53 +05:30
Dries Augustyns 18e17cf198 chore: Remove default tag from issue template 2026-01-11 11:53:36 +01:00
Dries Augustyns 6a6772d5ed Update issue templates 2026-01-11 11:51:38 +01:00
Dries Augustyns 50042cea27 chore: Clean up variable replacement paths 2026-01-11 09:09:41 +01:00
Dries Augustyns 3ccf890a21 chore: Clean up variable replacement paths 2026-01-11 08:57:19 +01:00
Dries Augustyns d57f6c81cb chore: Clean up variable replacement paths 2026-01-11 08:37:01 +01:00
Dries Augustyns c07816c2a1 fix: Update URL replacement logic to handle runtime paths and add warnings for missing files 2026-01-11 08:26:59 +01:00
Dries Augustyns 73da0a5136 Merge pull request #257 from mmihalev/next 2026-01-10 10:20:57 +01:00
Milen Mihalev d12b797ebe Use environment variables for SMTP port configuration 2026-01-09 15:38:59 +02:00
Dries Augustyns 7df43d8553 fix: Improve bounce handling logic to differentiate between permanent and transient bounces 2026-01-09 11:48:20 +01:00
Dries Augustyns b1047590e6 Merge pull request #250 from useplunk/release-please--branches--next--components--plunk 2026-01-09 08:41:30 +01:00
github-actions[bot] e3cf0fe0c1 chore(next): release 0.4.0 2026-01-08 13:19:17 +00:00
Dries Augustyns 940c8938f1 fix: Refactor CORS handling to allow unrestricted access for public API endpoints 2026-01-08 14:18:49 +01:00
Dries Augustyns e75e07f73f fix: Add better validation for sender email 2026-01-08 09:04:50 +01:00
Dries Augustyns 940a4d225b docs: Add more details about personalisation 2026-01-07 17:53:08 +01:00
Dries Augustyns 7386441e61 feat: Add id as reserved field in templates, campaigns and workflows 2026-01-07 17:49:36 +01:00
Dries Augustyns 718251c67c fix: Enhance CORS handling to allow requests with rejection logging 2026-01-07 13:11:54 +01:00
Dries Augustyns b6c5471d27 fix: Update template fetching to use Template type and simplify body access 2026-01-06 07:46:28 +01:00
Dries Augustyns fa22b8220a fix: Update templates data fetching to use PaginatedResponse type 2026-01-06 07:35:12 +01:00
Dries Augustyns a5c575444b fix: Catch unknown content-type headers 2026-01-04 17:27:27 +01:00
Dries Augustyns 4dce71a1fe fix: Reentry into segment not working after exit 2026-01-03 11:40:27 +01:00
Dries Augustyns ee00eb3481 fix: Update sentCount on campaign sent for correct overview stats 2026-01-03 11:18:44 +01:00
Dries Augustyns 5d44b1606d chore: Throttle notifications for billing limits and security warnings 2026-01-03 10:18:28 +01:00
Dries Augustyns e732c76490 feat: Add forwarding domains as verification check 2026-01-03 09:54:17 +01:00
Dries Augustyns fb02051538 feat: Add email verification on signup 2026-01-02 15:41:50 +01:00
Dries Augustyns 6f3853de59 test: Migrate test to use new pagination format 2026-01-02 10:50:53 +01:00
Dries Augustyns 8790c45edc fix: Migrate over to new pagination format in dashboard 2026-01-02 10:40:39 +01:00
Dries Augustyns 49824aff93 fix: Copy types build files 2026-01-02 10:20:07 +01:00
Dries Augustyns 492beb095f fix: Check email volume for 7-day window 2026-01-02 08:56:32 +01:00
Dries Augustyns d11c4af4d1 Merge pull request #251 from useplunk/dependabot/npm_and_yarn/qs-6.14.1 2026-01-01 14:28:38 +01:00
Dries Augustyns 82a7c8490e Merge branch 'next' into dependabot/npm_and_yarn/qs-6.14.1 2026-01-01 14:28:29 +01:00
Dries Augustyns 457c829b2d feat: Add cooldown to resend verification email 2026-01-01 14:26:26 +01:00
Dries Augustyns da7f3e5718 chore: Remove comments 2026-01-01 14:08:08 +01:00
Dries Augustyns 76786b2eae chore: Include types as dep for shared 2026-01-01 11:02:31 +01:00
Dries Augustyns 3a299cae98 chore: Include types as dep for shared 2026-01-01 10:58:39 +01:00
Dries Augustyns dd4737cfe9 chore: Include types as dep for shared 2026-01-01 10:56:59 +01:00
dependabot[bot] 02416654b6 chore(deps): bump qs from 6.14.0 to 6.14.1
Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-01 09:55:20 +00:00
Dries Augustyns 85c992a9f9 types: Abstract inline interfaces to @plunk/types 2026-01-01 10:52:43 +01:00
Dries Augustyns 38da58e5e9 types: Abstract inline interfaces to @plunk/types 2026-01-01 09:09:03 +01:00
Dries Augustyns 519b131792 chore: Rely more on services in auth middleware 2025-12-31 16:51:44 +01:00
Dries Augustyns be2eb57369 chore: Consolidate membership checks in single service 2025-12-31 16:20:17 +01:00
Dries Augustyns 19554e6e8f feat: Add platform emails for domain verification and expiration 2025-12-31 15:56:40 +01:00
Dries Augustyns 68b7e7c082 tests: fix date calculation for previous month in billing limit tests 2025-12-31 15:20:28 +01:00
Dries Augustyns 7648f6e1eb chore: Replace references to deprecated services 2025-12-31 15:08:26 +01:00
Dries Augustyns 289c633189 chore: Release please push 2025-12-30 13:29:16 +01:00
Dries Augustyns 857bb8ac9e chore: Default variables on Plunk Hosted 2025-12-30 13:28:04 +01:00
256 changed files with 11297 additions and 17004 deletions
+19 -4
View File
@@ -27,10 +27,10 @@ WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# 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
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
# This affects how application URIs are auto-generated from domain names
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
# Default: true
# AUTO_PROJECT_DISABLE=false
# ========================================
# OPTIONAL: Self-Hosting User Management
# ========================================
# Controls whether new user signups are allowed
# When enabled (true), the signup endpoint will reject new user registration attempts
# Useful for private instances or when you want to manually manage users
# Default: false
# DISABLE_SIGNUPS=false
# Controls whether email validation checks are performed on signup
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
# When disabled (false), skips these validation checks and allows any email format
# Default: false
# VERIFY_EMAIL_ON_SIGNUP=false
# ========================================
# ADVANCED (rarely needed)
# ========================================
@@ -0,0 +1,21 @@
---
name: Feature requests
about: Use this template to suggest new features for Plunk
title: ''
labels: enhancement
assignees: ''
---
## Is your feature request related to a problem?
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
## Describe the solution you'd like
A clear and concise description of what you want to happen.
## Alternatives or workarounds
A clear and concise description of any alternative solutions or features you've considered.
## Additional context
Add any other context or screenshots about the feature request here.
+61
View File
@@ -0,0 +1,61 @@
---
name: Product Issues
about: Use this template for bugs and issues on Plunk
title: ''
labels: bug
assignees: ''
---
## Description
## To Reproduce
## Expected behavior
## Environment
Please select the option that applies:
* Deployment type:
* [ ] Hosted
* [ ] Self-hosted
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
## Version verification
> ⚠️ **Important:**
> Self-hosted issues **must** be reproduced on the latest commit.
> Issues without a confirmed commit SHA may be closed without investigation.
* [ ] I am using the hosted version
**If self-hosted:**
* [ ] I have confirmed this issue still exists on the **latest commit** (not the `latest` tag)
* Commit SHA tested: `__________`
## Logs / Error output
If applicable, add logs, stack traces, or error messages here.
```
PASTE LOGS HERE
```
## Screenshots / Recordings / Additional context
---
## Checklist
Please confirm the following before submitting:
* [ ] I have searched existing issues to ensure this bug has not already been reported
* [ ] I have provided clear reproduction steps
* [ ] I have included all relevant environment details
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Run linter
run: yarn lint
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.3.0"
".": "0.7.0"
}
+1 -1
View File
@@ -2,7 +2,7 @@ nodeLinker: node-modules
# Support multiple architectures - download pre-built binaries instead of compiling
supportedArchitectures:
os: [ "linux", "darwin" ]
os: [ "linux", "darwin", "win32" ]
cpu: [ "x64", "arm64" ]
libc: [ "glibc" ]
+126
View File
@@ -1,5 +1,131 @@
# Changelog
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
### Features
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
### Bug Fixes
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
### Documentation
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
### Features
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
### Bug Fixes
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
### Documentation
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
### Features
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
### Bug Fixes
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
### Code Refactoring
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
### Documentation
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
### Features
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
### Bug Fixes
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
### Documentation
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
+21 -7
View File
@@ -64,12 +64,12 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
### 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)
- 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 (next-app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
### Background Job Architecture
@@ -108,6 +108,7 @@ between groups.
- Consistent type imports preferred: `import type { ... }`
- Unused vars allowed with `_` prefix
- Strict type checking enabled across all packages
- Try to avoid inline types in favor of shared types in `@plunk/types`
### Component Structure
@@ -147,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- 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)
- Self-hosting User Management (optional):
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
plus-addressing, domain existence, and MX records
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
bounce/complaint rate thresholds are exceeded
**Important Notes:**
@@ -155,4 +162,11 @@ Required for builds and deployment (see turbo.json and .env.example):
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
client-side access
## Plugins
There are two plugins installed for you to use.
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
+3 -3
View File
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
- **api**: Express.js API server with background worker process (BullMQ)
- **web**: Next.js dashboard application (app.useplunk.com)
- **landing**: Next.js marketing site (www.useplunk.com)
- **wiki**: Next.js documentation site (docs.useplunk.com)
- **landing**: Next.js marketing site (next.useplunk.com)
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
### Shared Packages (`packages/`)
@@ -135,7 +135,7 @@ between groups.
## Need Help?
- Check the [documentation](https://docs.useplunk.com)
- Check the [documentation](https://next-wiki.useplunk.com)
- Open an issue for bugs or feature requests
- Join our community discussions
+5 -6
View File
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
# Build-time arguments for URL configuration
# These are only used during the build process (for wiki OpenAPI generation and static assets)
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
ARG API_URI=https://api.useplunk.com
ARG DASHBOARD_URI=https://app.useplunk.com
ARG LANDING_URI=https://www.useplunk.com
ARG WIKI_URI=https://docs.useplunk.com
ARG API_URI=https://next-api.useplunk.com
ARG DASHBOARD_URI=https://next-app.useplunk.com
ARG LANDING_URI=https://next.useplunk.com
ARG WIKI_URI=https://next-wiki.useplunk.com
WORKDIR /app
@@ -273,8 +273,7 @@ COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/sh
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
# @plunk/types exports source TypeScript files directly (no build output)
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
# Copy Prisma schema (needed for migrations at runtime)
+1
View File
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
USE_HTTPS=false
API_URI=http://localhost:8080
WIKI_URI=http://localhost:1000
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
+1 -1
View File
@@ -37,7 +37,7 @@
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.0.2",
"multer": "^2.1.1",
"signale": "^1.4.0",
"stripe": "^20.0.0"
},
@@ -10,7 +10,7 @@ import {
NotAuthenticated,
NotFound,
RateLimitError,
ValidationError
ValidationError,
} from '../../exceptions/index.js';
import {EmailService} from '../../services/EmailService.js';
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
expect(result.data.subscribed).toBeUndefined();
}
});
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
const newEmail = '[email protected]';
// Send email to new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
// Transactional emails should create contacts as unsubscribed by default
expect(contact.subscribed).toBe(false);
});
});
describe('/v1/track endpoint', () => {
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
// Track event for new contact without specifying subscribed
const {ContactService} = await import('../../services/ContactService.js');
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
// New contacts should default to subscribed=true
// Event tracking should create contacts as subscribed by default
expect(contact.subscribed).toBe(true);
});
@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Note: To run these integration tests, you need to:
+88 -10
View File
@@ -20,7 +20,7 @@ import {
SMTP_ENABLED,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI
WIKI_URI,
} from './app/constants.js';
import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js';
@@ -76,23 +76,73 @@ const server = new (class extends Server {
// Log all requests to database for historical tracking and analytics
this.app.use(databaseRequestLogger);
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
res.set({'Access-Control-Allow-Origin': '*'});
next();
});
// Build allowed origins from environment variables
const allowedOrigins =
NODE_ENV === 'development'
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
this.app.use(
// Public API endpoints that should allow all origins
const publicApiPaths = ['/v1', '/v1/track', '/v1/send'];
// Log CORS configuration on startup
signale.info('CORS configuration', {
environment: NODE_ENV,
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
publicApiPaths,
});
// Apply restrictive CORS to all routes EXCEPT public API endpoints
this.app.use((req, res, next) => {
// Check if this is a public API endpoint
const isPublicApi = publicApiPaths.some(path => req.path === path || req.path.startsWith(path + '/'));
if (isPublicApi) {
// For public API endpoints, allow all origins
res.set({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
});
// Handle preflight
if (req.method === 'OPTIONS') {
return res.sendStatus(200);
}
return next();
}
// For other endpoints, apply restrictive CORS
cors({
origin: allowedOrigins,
origin: (origin, callback) => {
// Allow requests with no origin (e.g., mobile apps, curl, server-to-server)
if (!origin) {
return callback(null, true);
}
// Check if origin matches any allowed origin (string or regex)
const isAllowed = allowedOrigins.some(allowed => {
if (allowed instanceof RegExp) {
return allowed.test(origin);
}
return allowed === origin;
});
if (isAllowed) {
callback(null, true);
} else {
// Log CORS rejection with helpful information
signale.warn('CORS request rejected', {
origin,
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
hint: 'If using HTTPS, ensure USE_HTTPS=true is set in your environment variables',
});
// Reject the CORS request by passing false (don't send CORS headers)
callback(null, false);
}
},
credentials: true,
}),
);
})(req, res, next);
});
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
@@ -154,6 +204,34 @@ interface ErrorResponse {
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
const requestId = res.locals.requestId as string | undefined;
// Handle JSON parsing errors (from express.json() middleware)
if (error instanceof SyntaxError && 'body' in error) {
const statusCode = 400;
logger.warn(
'JSON parsing failed',
{
endpoint: `${req.method} ${req.path}`,
contentType: req.get('content-type'),
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.VALIDATION_ERROR,
message: 'Invalid JSON in request body',
statusCode,
requestId,
suggestion: 'Ensure your request body is valid JSON and Content-Type header is set to "application/json".',
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle Zod validation errors
if (error instanceof ZodError) {
const fieldErrors: FieldError[] = error.errors.map(err => ({
+6
View File
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// Self-hosting Configuration (optional)
// Controls whether new user signups are allowed (default: false)
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
// Controls whether email validation checks are performed on signup (default: false)
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
// Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
+9 -5
View File
@@ -1,8 +1,6 @@
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';
@@ -52,7 +50,7 @@ export class Actions {
@Middleware([requirePublicKey])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
@@ -74,6 +72,8 @@ export class Actions {
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
// but preserve existing subscription state for existing contacts
const contact = await ContactService.upsert(
auth.projectId,
email,
@@ -173,7 +173,7 @@ export class Actions {
@Middleware([requireSecretKey])
@CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
@@ -268,7 +268,10 @@ export class Actions {
: (data as Record<string, unknown> | undefined);
// Create or update contact with metadata
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
// Transactional emails should not subscribe contacts by default
// New contacts default to unsubscribed unless explicitly opted in
// Existing contacts preserve their subscription state unless explicitly changed
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
// Get merged data including non-persistent fields for template rendering
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
@@ -277,6 +280,7 @@ export class Actions {
// These are always available for template rendering
const dataWithSystemVars = {
...mergedData,
id: contact.id,
email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
+6 -7
View File
@@ -1,9 +1,8 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {ActivityType} from '@plunk/types';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService, ActivityType} from '../services/ActivityService.js';
import {ActivityService} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity')
@@ -24,7 +23,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId as string | undefined;
@@ -65,7 +64,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -85,7 +84,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
@@ -117,7 +116,7 @@ export class Activity {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
+4 -6
View File
@@ -1,7 +1,5 @@
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';
@@ -22,7 +20,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -44,7 +42,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -68,7 +66,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -92,7 +90,7 @@ export class Analytics {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
+38
View File
@@ -7,6 +7,7 @@ import * as React from 'react';
import {
DASHBOARD_URI,
DISABLE_SIGNUPS,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
@@ -15,12 +16,14 @@ import {
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
VERIFY_EMAIL_ON_SIGNUP,
} 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 {EmailVerificationService} from '../services/EmailVerificationService.js';
import {NtfyService} from '../services/NtfyService.js';
import {UserService} from '../services/UserService.js';
import {Keys} from '../services/keys.js';
@@ -62,8 +65,43 @@ export class Auth {
@Post('signup')
@CatchAsync
public async signup(req: Request, res: Response, _next: NextFunction) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
return res.json({
success: false,
data: 'New user signups are currently disabled',
});
}
const {email, password} = AuthenticationSchemas.login.parse(req.body);
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
if (VERIFY_EMAIL_ON_SIGNUP) {
const verification = await EmailVerificationService.verifyEmail(email);
if (
verification.isDisposable ||
verification.isPlusAddressed ||
!verification.domainExists ||
!verification.hasMxRecords
) {
// Build list of reasons for notification
const reasons: string[] = [];
if (verification.isDisposable) reasons.push('disposable email');
if (verification.isPlusAddressed) reasons.push('plus addressing');
if (!verification.domainExists) reasons.push('domain does not exist');
if (!verification.hasMxRecords) reasons.push('no MX records');
// Send notification about failed signup attempt
await NtfyService.notifyFailedSignupAttempt(email, reasons);
return res.json({
success: false,
data: 'This email address cannot be used for signup',
});
}
}
const user = await UserService.email(email);
if (user) {
+11 -19
View File
@@ -4,7 +4,6 @@ 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';
@@ -20,11 +19,10 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
@@ -63,7 +61,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
@@ -79,13 +77,7 @@ export class Campaigns {
pageSize,
});
return res.json({
campaigns: result.campaigns,
page: result.page,
pageSize: result.pageSize,
total: result.total,
totalPages: result.totalPages,
});
return res.json(result);
}
/**
@@ -96,7 +88,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!);
@@ -115,7 +107,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
req.body;
@@ -161,7 +153,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!);
@@ -180,7 +172,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!);
@@ -200,7 +192,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor;
@@ -231,7 +223,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!);
@@ -251,7 +243,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!);
@@ -270,7 +262,7 @@ export class Campaigns {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body);
+4
View File
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
import {
API_URI,
AWS_SES_REGION,
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
@@ -59,6 +60,9 @@ export class Config {
: null,
},
},
aws: {
sesRegion: AWS_SES_REGION,
},
});
}
}
+17 -18
View File
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
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';
@@ -16,7 +14,6 @@ const upload = multer({
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true);
} else {
@@ -35,7 +32,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const search = req.query.search as string | undefined;
@@ -54,7 +51,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
@@ -80,7 +77,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
@@ -113,7 +110,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
@@ -133,7 +130,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {email, data, subscribed} = req.body;
if (!email) {
@@ -163,7 +160,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
const {email, data, subscribed} = req.body;
@@ -184,7 +181,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
@@ -284,7 +281,7 @@ export class Contacts {
@Middleware([requireAuth, upload.single('file')])
@CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!req.file) {
return res.status(400).json({error: 'CSV file is required'});
@@ -318,6 +315,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const jobId = req.params.jobId;
if (!jobId) {
@@ -325,7 +323,7 @@ export class Contacts {
}
try {
const status = await QueueService.getImportJobStatus(jobId);
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
if (!status) {
return res.status(404).json({error: 'Import job not found'});
@@ -349,7 +347,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
@@ -376,7 +374,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
@@ -402,7 +400,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -437,7 +435,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -471,7 +469,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
@@ -505,6 +503,7 @@ export class Contacts {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const jobId = req.params.jobId;
if (!jobId) {
@@ -512,7 +511,7 @@ export class Contacts {
}
try {
const status = await QueueService.getBulkActionJobStatus(jobId);
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
if (!status) {
return res.status(404).json({error: 'Bulk action job not found'});
+9 -52
View File
@@ -4,11 +4,10 @@ import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import type {AuthResponse} from '../middleware/auth.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {prisma} from '../database/prisma.js';
import {MembershipService} from '../services/MembershipService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
@@ -20,20 +19,11 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
@@ -47,7 +37,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) {
@@ -55,19 +45,7 @@ export class Domains {
}
// Verify user has admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission');
}
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
@@ -107,7 +85,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
@@ -117,16 +95,7 @@ export class Domains {
}
// Verify user has access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, domain.projectId);
const verificationStatus = await DomainService.checkVerification(id);
@@ -144,7 +113,7 @@ export class Domains {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
@@ -154,19 +123,7 @@ export class Domains {
}
// Verify user has admin access to the project this domain belongs to
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: domain.projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Domain not found or you do not have permission');
}
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
await DomainService.removeDomain(id);
+7 -9
View File
@@ -1,8 +1,6 @@
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';
@@ -17,7 +15,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, contactId, emailId, data} = req.body;
if (!name) {
@@ -37,7 +35,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100;
@@ -54,7 +52,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
@@ -71,7 +69,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50;
@@ -92,7 +90,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
@@ -108,7 +106,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
@@ -135,7 +133,7 @@ export class Events {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
DISABLE_SIGNUPS,
GITHUB_OAUTH_CLIENT,
GITHUB_OAUTH_ENABLED,
GITHUB_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
@@ -81,6 +83,11 @@ export class Github {
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
DISABLE_SIGNUPS,
GOOGLE_OAUTH_CLIENT,
GOOGLE_OAUTH_ENABLED,
GOOGLE_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
@@ -71,6 +73,11 @@ export class Google {
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
+21 -173
View File
@@ -4,8 +4,8 @@ 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 {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -19,20 +19,11 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get project with relevant data
const project = await prisma.project.findUnique({
@@ -92,20 +83,11 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, id);
// Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id);
@@ -124,43 +106,18 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get all members of the project
const members = await prisma.membership.findMany({
where: {
projectId: id,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
const members = await MembershipService.getMembers(id);
return res.json({
success: true,
data: members.map(m => ({
userId: m.user.id,
email: m.user.email,
role: m.role,
})),
data: members,
});
}
@@ -173,7 +130,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Validate params
@@ -190,19 +147,7 @@ export class Projects {
const {email, role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can add members');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Find user by email
const userToAdd = await prisma.user.findUnique({
@@ -214,28 +159,8 @@ export class Projects {
throw new HttpException(404, 'User with this email does not have an account');
}
// Check if user is already a member
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId: userToAdd.id,
projectId: id,
},
},
});
if (existingMembership) {
throw new HttpException(409, 'User is already a member of this project');
}
// Create membership
const newMembership = await prisma.membership.create({
data: {
userId: userToAdd.id,
projectId: id,
role,
},
});
// Add member to project
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
return res.json({
success: true,
@@ -256,7 +181,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
@@ -276,38 +201,7 @@ export class Projects {
const {role} = parseResult.data;
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can update member roles');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot change OWNER role
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot change the role of the project owner');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get user info
const user = await prisma.user.findUnique({
@@ -319,16 +213,8 @@ export class Projects {
throw new HttpException(404, 'User not found');
}
// Update role
await prisma.membership.update({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
data: {role},
});
// Update role (service handles validation)
await MembershipService.updateRole(id, userId, role);
return res.json({
success: true,
@@ -348,7 +234,7 @@ export class Projects {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
@@ -360,53 +246,15 @@ export class Projects {
}
// Verify current user is ADMIN or OWNER
const currentMembership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!currentMembership) {
throw new HttpException(403, 'Only project admins and owners can remove members');
}
// Get target membership
const targetMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
if (!targetMembership) {
throw new HttpException(404, 'Member not found');
}
// Cannot remove OWNER
if (targetMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot remove the project owner');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Cannot remove yourself
if (userId === auth.userId) {
throw new HttpException(403, 'You cannot remove yourself from the project');
}
// Delete membership
await prisma.membership.delete({
where: {
userId_projectId: {
userId,
projectId: id,
},
},
});
// Remove member (service handles validation)
await MembershipService.removeMember(id, userId);
return res.json({
success: true,
+65 -14
View File
@@ -1,7 +1,5 @@
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';
@@ -16,7 +14,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segments = await SegmentService.list(auth.projectId!);
@@ -31,7 +29,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -51,7 +49,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -73,21 +71,24 @@ export class Segments {
@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;
const auth = res.locals.auth;
const {name, description, type, 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 segmentType = type ?? 'DYNAMIC';
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
}
const segment = await SegmentService.create(auth.projectId!, {
name,
description,
condition,
type: segmentType,
condition: segmentType === 'DYNAMIC' ? condition : undefined,
trackMembership,
});
@@ -102,7 +103,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body;
@@ -132,7 +133,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -144,6 +145,56 @@ export class Segments {
return res.status(204).send();
}
/**
* POST /segments/:id/members
* Add contacts to a static segment by email
*/
@Post(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const {emails} = req.body;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
if (!Array.isArray(emails) || emails.length === 0) {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
return res.status(200).json(result);
}
/**
* DELETE /segments/:id/members
* Remove contacts from a static segment by email
*/
@Delete(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const {emails} = req.body;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
if (!Array.isArray(emails) || emails.length === 0) {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
return res.status(200).json(result);
}
/**
* POST /segments/:id/compute
* Recompute segment membership for all contacts
@@ -152,7 +203,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
@@ -172,7 +223,7 @@ export class Segments {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
+7 -9
View File
@@ -1,8 +1,6 @@
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';
@@ -18,7 +16,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
@@ -37,7 +35,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -57,7 +55,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) {
@@ -101,7 +99,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
@@ -136,7 +134,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -156,7 +154,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
@@ -176,7 +174,7 @@ export class Templates {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
+1 -4
View File
@@ -2,8 +2,6 @@ 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';
@@ -15,7 +13,6 @@ const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (_req, file, cb) => {
// Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) {
@@ -36,7 +33,7 @@ export class Uploads {
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
try {
if (!S3Service.isS3Enabled()) {
+32 -151
View File
@@ -8,9 +8,9 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON
import {stripe} from '../app/stripe.js';
import {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 {MembershipService} from '../services/MembershipService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {UserService} from '../services/UserService.js';
@@ -23,7 +23,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
@@ -42,7 +42,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
@@ -57,7 +57,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
@@ -104,24 +104,12 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body);
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update it');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Update the project
const project = await prisma.project.update({
@@ -136,23 +124,11 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to regenerate keys');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
@@ -188,7 +164,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
@@ -197,20 +173,8 @@ export class Users {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -280,7 +244,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
@@ -288,20 +252,8 @@ export class Users {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to manage billing');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -330,7 +282,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
@@ -342,16 +294,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing limits');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
@@ -363,7 +306,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
@@ -377,19 +320,7 @@ export class Users {
const data = BillingLimitSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to update billing limits');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project with current limits
const project = await prisma.project.findUnique({
@@ -399,6 +330,7 @@ export class Users {
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
@@ -418,6 +350,7 @@ export class Users {
billingLimitWorkflows: data.workflows,
billingLimitCampaigns: data.campaigns,
billingLimitTransactional: data.transactional,
billingLimitInbound: data.inbound,
},
});
@@ -429,6 +362,7 @@ export class Users {
workflows: project.billingLimitWorkflows,
campaigns: project.billingLimitCampaigns,
transactional: project.billingLimitTransactional,
inbound: project.billingLimitInbound,
},
data,
);
@@ -442,7 +376,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
@@ -459,16 +393,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
await MembershipService.requireAccess(auth.userId!, id);
const project = await prisma.project.findUnique({
where: {id},
@@ -568,7 +493,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
@@ -585,16 +510,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view billing');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
@@ -656,7 +572,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
@@ -668,16 +584,7 @@ export class Users {
}
// Verify user has access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to view security metrics');
}
await MembershipService.requireAccess(auth.userId!, id);
// Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id);
@@ -689,7 +596,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
@@ -701,19 +608,7 @@ export class Users {
}
// Verify user has admin/owner access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
if (!membership) {
throw new NotFound('Project not found or you do not have permission to reset it');
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id);
@@ -775,7 +670,7 @@ export class Users {
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
@@ -787,21 +682,7 @@ export class Users {
}
// Verify user has owner or admin access to this project
const membership = await prisma.membership.findFirst({
where: {
userId: auth.userId,
projectId: id,
role: {
in: ['OWNER', 'ADMIN'],
},
},
});
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.',
);
}
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get project to check for active subscription and disabled status
const project = await prisma.project.findUnique({
+234 -29
View File
@@ -1,6 +1,6 @@
import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailStatus} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import signale from 'signale';
import type Stripe from 'stripe';
@@ -8,7 +8,10 @@ import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {BillingLimitService} from '../services/BillingLimitService.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -21,7 +24,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
export class Webhooks {
/**
* Receive SNS webhook notifications from AWS SES
* Handles email events: delivery, open, click, bounce, complaint
* Handles outbound email events: delivery, open, click, bounce, complaint
* Handles inbound email notifications: received emails via SES receiving
*/
@Post('sns')
@CatchAsync
@@ -30,11 +34,35 @@ export class Webhooks {
// 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);
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
// Legitimate URLs look like:
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
const subscribeURL: unknown = req.body.SubscribeURL;
if (typeof subscribeURL !== 'string') {
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
let parsedURL: URL;
try {
parsedURL = new URL(subscribeURL);
} catch {
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
// Only allow HTTPS requests to official AWS SNS endpoints.
// The hostname must be exactly sns.<region>.amazonaws.com.
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
}
// Automatically confirm the subscription
try {
const confirmResponse = await fetch(req.body.SubscribeURL);
const confirmResponse = await fetch(subscribeURL);
if (confirmResponse.ok) {
signale.success('SNS subscription confirmed successfully');
return res.status(200).json({
@@ -46,7 +74,6 @@ export class Webhooks {
return res.status(200).json({
success: false,
message: 'Failed to confirm subscription',
subscribeURL: req.body.SubscribeURL,
});
}
} catch (confirmError) {
@@ -54,7 +81,6 @@ export class Webhooks {
return res.status(200).json({
success: false,
message: 'Error confirming subscription',
subscribeURL: req.body.SubscribeURL,
});
}
}
@@ -67,6 +93,150 @@ export class Webhooks {
// Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message);
// Check if this is an inbound email notification (SES Receiving)
if (body.notificationType === 'Received') {
signale.info('[WEBHOOK] Received inbound email notification from SES');
try {
// Extract recipient addresses from the inbound email
const recipients = body.receipt?.recipients || [];
if (recipients.length === 0) {
signale.warn('[WEBHOOK] No recipients found in inbound email');
return res.status(200).json({success: true, message: 'No recipients found'});
}
// For each recipient, identify the domain and create events
for (const recipient of recipients) {
const recipientEmail = recipient as string;
const domain = recipientEmail.split('@')[1];
if (!domain) {
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
continue;
}
// Find ALL projects that have this domain verified
// A domain can be shared across multiple projects if users are members of both
const domainRecords = await prisma.domain.findMany({
where: {
domain,
verified: true, // Only process emails for verified domains
},
include: {
project: true,
},
});
if (domainRecords.length === 0) {
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
continue;
}
signale.info(
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
);
// Extract sender information (same for all projects)
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
// Check billing limits before processing inbound email
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
if (!limitCheck.allowed) {
signale.warn(
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
);
continue; // Skip this project but continue processing for other projects
}
// Find or create a contact for the sender in this project
let contact;
if (senderEmail) {
contact = await ContactService.upsert(
domainRecord.projectId,
senderEmail,
undefined, // No additional data
true, // Subscribe by default for inbound email senders
);
}
// Create an Email record for tracking (no actual email content since it's inbound)
const inboundEmail = await prisma.email.create({
data: {
projectId: domainRecord.projectId,
contactId: contact!.id,
subject: body.mail?.commonHeaders?.subject || '(No subject)',
body: '', // Inbound emails don't have body content in our system
from: recipientEmail, // The recipient address that received the email
sourceType: EmailSourceType.INBOUND,
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
deliveredAt: new Date(body.mail?.timestamp || new Date()),
},
});
// Increment usage counter in cache
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
// Record Stripe metering if project has customer
if (domainRecord.project.customer) {
await MeterService.recordEmailSent(
domainRecord.project.customer,
1, // Inbound emails count as 1 credit
`email_${inboundEmail.id}`,
);
}
// Prepare event data with all inbound email details
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
fromHeader: senderFromHeader,
to: recipientEmail,
subject: body.mail?.commonHeaders?.subject,
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
spfVerdict: body.receipt?.spfVerdict?.status,
dkimVerdict: body.receipt?.dkimVerdict?.status,
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
// Processing metadata
processingTimeMillis: body.receipt?.processingTimeMillis,
};
// Create the email.received event (this will trigger workflows)
await EventService.trackEvent(
domainRecord.projectId,
'email.received',
contact?.id,
inboundEmail.id, // Link the event to the inbound email record
eventData,
);
signale.success(
`[WEBHOOK] Created email.received event for ${senderEmail}${recipientEmail} (project: ${domainRecord.project.name})`,
);
}
}
return res.status(200).json({success: true, message: 'Inbound email processed'});
} catch (inboundError) {
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
// Return 200 to acknowledge receipt even if processing failed
return res.status(200).json({success: true, message: 'Error processing inbound email'});
}
}
// Handle outbound email event notifications (existing logic)
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId;
@@ -152,29 +322,62 @@ export class Webhooks {
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(),
};
case 'Bounce': {
const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
// Send notification about bounce
await NtfyService.notifyEmailBounce(
email.project.name,
email.projectId,
email.contact.email,
body.bounce?.bounceType,
);
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on permanent bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
// Send notification about permanent bounce
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
} else if (isTransientBounce) {
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
signale.info(
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
);
// Don't update email status or unsubscribe contact
// Just track the event for visibility
eventData = {
...baseEventData,
bounceType,
transientBounce: true,
};
} else {
// Unknown bounce type - treat as permanent to be safe
signale.warn(
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType,
bouncedAt: now.toISOString(),
};
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
}
break;
}
case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
@@ -208,8 +411,10 @@ export class Webhooks {
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events
if (eventType === 'Bounce' || eventType === 'Complaint') {
// Check security limits only for permanent bounces and complaints
// Transient bounces (soft bounces) don't count toward bounce rate
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
if (isPermanentBounce || eventType === 'Complaint') {
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
}
+16 -18
View File
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
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';
@@ -18,7 +16,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
@@ -38,7 +36,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
try {
@@ -61,7 +59,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
@@ -81,7 +79,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) {
@@ -111,7 +109,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
@@ -139,7 +137,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
@@ -159,7 +157,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body;
@@ -191,7 +189,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body;
@@ -218,7 +216,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
@@ -239,7 +237,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
@@ -269,7 +267,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const transitionId = req.params.transitionId;
@@ -290,7 +288,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const {contactId, context} = req.body;
@@ -315,7 +313,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
@@ -338,7 +336,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
@@ -359,7 +357,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
@@ -380,7 +378,7 @@ export class Workflows {
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
+1 -3
View File
@@ -74,9 +74,7 @@ export class NotFound extends HttpException {
* @param id Optional resource identifier to include in the message
*/
public constructor(resource: string, id?: string) {
const message = id
? `${resource} with ID "${id}" was not found`
: `That ${resource.toLowerCase()} was not found`;
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = {
@@ -1,6 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
status: EmailStatus.PENDING,
});
// Mock the email processor logic
// In a real implementation, you would:
// 1. Create job tester
// 2. Mock SES service
// 3. Process the job
// 4. Verify status changes
// Simulate processing
await prisma.email.update({
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
from: '[email protected]',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
attachments: toPrismaJson([
{
filename: 'document.pdf',
content: 'base64encodedcontent',
contentType: 'application/pdf',
},
] as unknown as Prisma.InputJsonValue,
]),
},
});
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
from: '[email protected]',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: [
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
},
include: {
project: true,
@@ -1,6 +1,6 @@
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => {
let projectId: string;
@@ -1,3 +1,4 @@
import type {ApiRequestCleanupJobData} from '@plunk/types';
import type {Job} from 'bullmq';
import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis';
@@ -5,7 +6,6 @@ import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
/**
* API Request Cleanup Worker
+2 -1
View File
@@ -3,11 +3,12 @@
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import type {BulkContactActionJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {ContactService} from '../services/ContactService.js';
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
+2 -1
View File
@@ -3,11 +3,12 @@
* Processes campaign batches (queues emails for each contact in the batch)
*/
import type {CampaignBatchJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
import {campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>(
@@ -3,10 +3,11 @@
* Processes domain verification jobs from the BullMQ queue
*/
import type {DomainVerificationJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
import {domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js';
+81 -3
View File
@@ -6,10 +6,14 @@
* Scheduled to run every 5 minutes via repeatable jobs
*/
import React from 'react';
import signale from 'signale';
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {MembershipService} from '../services/MembershipService.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
import {Keys} from '../services/keys.js';
@@ -26,7 +30,15 @@ export async function checkDomainVerifications() {
// Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({
select: {id: true, domain: true, projectId: true, verified: true},
select: {
id: true,
domain: true,
projectId: true,
verified: true,
project: {
select: {name: true},
},
},
skip: i,
take: 99,
});
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
} catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string};
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
if (
error?.Code === 'Throttling' ||
error?.name === 'Throttling' ||
error?.message?.includes('Throttling')
) {
signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
signale.error(
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
);
throw e;
}
}
@@ -102,6 +120,34 @@ export async function checkDomainVerifications() {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
}
// Send email notification about domain verified
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
const ttl = 604800; // 7 days
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(dbDomain.projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainVerifiedEmail, {
projectName: dbDomain.project.name,
projectId: dbDomain.projectId,
domain: sesIdentity.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
);
}
}
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`);
}
// Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
@@ -111,6 +157,38 @@ export async function checkDomainVerifications() {
if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
// Send email notification about domain verification failed
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(dbDomain.projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainUnverifiedEmail, {
projectName: dbDomain.project.name,
projectId: dbDomain.projectId,
domain: sesIdentity.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
);
}
}
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`);
}
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
+22 -4
View File
@@ -4,16 +4,17 @@
*/
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import type {SendEmailJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
import {emailQueue} 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)
@@ -116,10 +117,25 @@ export async function createEmailWorker() {
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;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Build recipient with name if available
const recipient: {name?: string; email: string} | string = email.toName
? {name: email.toName, email: email.contact.email}
: email.contact.email;
? {name: email.toName, email: recipientEmail}
: recipientEmail;
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
@@ -136,6 +152,7 @@ export async function createEmailWorker() {
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: publicHeaders,
tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
});
@@ -203,6 +220,7 @@ export async function createEmailWorker() {
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
sentCount,
},
});
+2 -1
View File
@@ -3,6 +3,7 @@
* Processes CSV contact imports with validation and batch processing
*/
import type {ContactImportJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import signale from 'signale';
@@ -10,7 +11,7 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {NtfyService} from '../services/NtfyService.js';
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
import {importQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
+2 -1
View File
@@ -4,12 +4,13 @@
*/
import {CampaignStatus} from '@plunk/db';
import type {ScheduledCampaignJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
import {scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>(
+23 -37
View File
@@ -3,12 +3,13 @@
* Processes segment count update jobs from the BullMQ queue
*/
import type {SegmentCountJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {NtfyService} from '../services/NtfyService.js';
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
import {segmentCountQueue} from '../services/QueueService.js';
import {SegmentService} from '../services/SegmentService.js';
/**
@@ -17,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
* - 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},
@@ -28,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
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)
let updatedSegmentCount = 0;
let totalAdded = 0;
let totalRemoved = 0;
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,
);
// Track segments with actual changes for bundled notification
if (result.added > 0 || result.removed > 0) {
updatedSegmentCount++;
totalAdded += result.added;
totalRemoved += result.removed;
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
// Continue with other segments
}
}
// Send bundled notification if there were any changes
if (projectName && updatedSegmentCount > 0) {
await NtfyService.notifySegmentMembershipBundled(
projectName,
projectId,
updatedSegmentCount,
totalAdded,
totalRemoved,
);
}
}
// Process non-tracked segments with count-only update (lightweight)
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);
}
@@ -79,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
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({
@@ -94,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
select: {id: true, name: true},
});
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10;
@@ -104,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
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
@@ -119,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
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);
@@ -147,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
},
);
worker.on('completed', job => {
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
});
@@ -3,10 +3,11 @@
* Processes workflow steps from the queue (for delayed steps)
*/
import type {WorkflowStepJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
import {workflowQueue} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() {
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
projectId = project.id;
userId = user.id;
// Mock request object
req = {
method: 'POST',
path: '/v1/send',
@@ -334,12 +333,7 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// TODO: Add assertion to verify request was NOT logged when disabled
// const loggedRequest = await prisma.apiRequest.findUnique({
// where: {id: 'test-request-id-123'},
// });
// expect(loggedRequest).toBeNull();
// Restore original value
if (originalEnv !== undefined) {
process.env.REQUEST_LOGGING = originalEnv;
+28 -109
View File
@@ -2,15 +2,13 @@ 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';
import type {AuthResponse} from '@plunk/types';
export interface AuthResponse {
type: 'jwt' | 'apiKey';
userId?: string;
projectId: string;
}
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
import {MembershipService} from '../services/MembershipService.js';
import {ProjectService} from '../services/ProjectService.js';
import {UserService} from '../services/UserService.js';
/**
* Middleware to check if this unsubscribe is authenticated on the dashboard
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
return id;
}
/**
* Middleware to require project access
* Validates that the user is authenticated and has access to the project specified in X-Project-Id header
* @param req
* @param res
* @param next
*/
export const requireProjectAccess = async (req: Request, res: Response, next: NextFunction) => {
try {
// First authenticate the user
const userId = parseJwt(req);
// Get project ID from header
const projectId = req.headers['x-project-id'] as string | undefined;
if (!projectId) {
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
}
// Verify user has access to this project and get project status
const [membership, project] = await Promise.all([
prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
}),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
]);
if (!membership) {
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// 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
@@ -174,12 +106,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by public key only
const project = await prisma.project.findFirst({
where: {
public: apiKey,
},
});
const project = await ProjectService.public(apiKey);
if (!project) {
throw new HttpException(
@@ -193,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
};
// Check if project is disabled - block write operations
if (project.disabled) {
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by secret key only
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
const project = await ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
@@ -262,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
};
// Check if project is disabled - block write operations
if (project.disabled) {
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
}
const apiKey = parts[1];
if (!apiKey) {
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
}
// Look up project by secret key only (public keys not allowed)
const project = await prisma.project.findFirst({
where: {
secret: apiKey,
},
});
const project = await ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
@@ -360,18 +292,8 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
// Verify user has access to this project and get project status
const [membership, project] = await Promise.all([
prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
}),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
MembershipService.getMembership(userId, projectId),
ProjectService.id(projectId),
]);
if (!membership) {
@@ -383,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
type: 'jwt',
userId,
projectId,
} as AuthResponse;
};
// Check if project is disabled - block write operations
if (project?.disabled) {
@@ -414,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
*/
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = res.locals.auth as AuthResponse;
const auth = res.locals.auth;
if (auth.type === 'apiKey') {
return next();
@@ -424,10 +346,7 @@ export const requireEmailVerified = async (req: Request, res: Response, next: Ne
throw new NotAuthenticated();
}
const user = await prisma.user.findUnique({
where: {id: auth.userId},
select: {emailVerified: true, type: true},
});
const user = await UserService.id(auth.userId);
if (!user) {
throw new NotAuthenticated();
+102 -65
View File
@@ -1,61 +1,12 @@
import type {Prisma} from '@plunk/db';
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
import {ActivityType} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
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
*
@@ -107,7 +58,7 @@ export class ActivityService {
contactId?: string,
startDate?: Date,
endDate?: Date,
): Promise<PaginatedActivities> {
): Promise<CursorPaginatedResponse<Activity>> {
// Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
@@ -158,8 +109,8 @@ export class ActivityService {
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return {
activities: results,
nextCursor,
data: results,
cursor: nextCursor,
hasMore,
};
}
@@ -415,15 +366,41 @@ export class ActivityService {
...(contactId ? {contactId} : {}),
};
// Build OR conditions to filter by the appropriate timestamp field for each activity type
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
const orConditions: Prisma.EmailWhereInput[] = [];
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
orConditions.push({sentAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_RECEIVED)) {
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
}
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
orConditions.push({openedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
orConditions.push({clickedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
orConditions.push({complainedAt: {not: null, ...dateFilter}});
}
// If no OR conditions, return empty (shouldn't happen but defensive)
if (orConditions.length === 0) {
return [];
}
const emails = await prisma.email.findMany({
where: {
...where,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
OR: orConditions,
},
orderBy: {createdAt: 'desc'},
take: limit,
@@ -450,6 +427,27 @@ export class ActivityService {
},
});
// Helper function to check if timestamp is within date range
const isInDateRange = (timestamp: Date | null) => {
if (!timestamp) return false;
// Check against date filter
const time = timestamp.getTime();
if (dateFilter.gte) {
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
if (time < gteTime) return false;
}
if (dateFilter.lte) {
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
if (time > lteTime) return false;
}
// Check against cursor for pagination
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
return true;
};
// Convert each email into multiple activities based on its state
for (const email of emails) {
const baseMetadata = {
@@ -464,7 +462,7 @@ export class ActivityService {
workflowName: email.workflowExecution?.workflow?.name,
};
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
activities.push({
id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT,
@@ -475,7 +473,11 @@ export class ActivityService {
});
}
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
if (
email.deliveredAt &&
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
isInDateRange(email.deliveredAt)
) {
activities.push({
id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED,
@@ -486,7 +488,23 @@ export class ActivityService {
});
}
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
if (
email.deliveredAt &&
email.sourceType === 'INBOUND' &&
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
isInDateRange(email.deliveredAt)
) {
activities.push({
id: `${email.id}_received`,
type: ActivityType.EMAIL_RECEIVED,
timestamp: email.deliveredAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
activities.push({
id: `${email.id}_opened`,
type: ActivityType.EMAIL_OPENED,
@@ -501,7 +519,7 @@ export class ActivityService {
}
// Email clicked
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
activities.push({
id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED,
@@ -516,7 +534,7 @@ export class ActivityService {
}
// Email bounced
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
activities.push({
id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED,
@@ -529,6 +547,25 @@ export class ActivityService {
},
});
}
// Email complaint
if (
email.complainedAt &&
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
isInDateRange(email.complainedAt)
) {
activities.push({
id: `${email.id}_complaint`,
type: ActivityType.EMAIL_COMPLAINT,
timestamp: email.complainedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
}
return activities;
+16 -5
View File
@@ -58,14 +58,16 @@ export class AnalyticsService {
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
const cacheKey = Keys.Analytics.timeseries(
projectId,
limitedStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
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;
@@ -191,7 +193,11 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cacheKey = Keys.Analytics.campaignStats(
projectId,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
@@ -295,7 +301,12 @@ export class AnalyticsService {
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
const cacheKey = Keys.Analytics.topEvents(
projectId,
limit,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
+50 -62
View File
@@ -1,4 +1,5 @@
import {EmailSourceType} from '@plunk/db';
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
@@ -8,41 +9,9 @@ import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
/**
* Usage information for a specific email category
*/
export interface CategoryUsage {
limit: number | null; // null = unlimited
usage: number;
percentage: number; // 0-100
isWarning: boolean; // true if >= 80%
isBlocked: boolean; // true if >= 100%
}
/**
* Complete billing limits and usage for a project
*/
export interface BillingLimitsResponse {
workflows: CategoryUsage;
campaigns: CategoryUsage;
transactional: CategoryUsage;
currency: string | null;
}
/**
* Result of limit check
*/
export interface LimitCheckResult {
allowed: boolean;
warning: boolean; // true if >= 80% but < 100%
usage: number;
limit: number | null;
percentage: number;
message?: string;
}
/**
* Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category
@@ -188,6 +157,7 @@ export class BillingLimitService {
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
@@ -214,6 +184,9 @@ export class BillingLimitService {
case EmailSourceType.TRANSACTIONAL:
limit = project.billingLimitTransactional;
break;
case EmailSourceType.INBOUND:
limit = project.billingLimitInbound;
break;
default:
limit = null;
}
@@ -222,7 +195,8 @@ export class BillingLimitService {
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== null ||
project.billingLimitTransactional !== null;
project.billingLimitTransactional !== null ||
project.billingLimitInbound !== null;
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
// Only enforce free tier limits if billing is enabled
@@ -399,6 +373,7 @@ export class BillingLimitService {
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
@@ -421,10 +396,11 @@ export class BillingLimitService {
}
// Get usage for all categories in parallel
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
this.getUsage(projectId, EmailSourceType.WORKFLOW),
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
this.getUsage(projectId, EmailSourceType.INBOUND),
]);
// Helper to calculate category usage
@@ -443,18 +419,19 @@ export class BillingLimitService {
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== null ||
project.billingLimitTransactional !== null;
project.billingLimitTransactional !== null ||
project.billingLimitInbound !== null;
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
// Only show free tier limits if billing is enabled
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
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
// For free tier, show the same limit and total usage for all four categories
// This makes it clear in the UI that it's a shared limit
const sharedUsageInfo: CategoryUsage = {
limit,
@@ -468,6 +445,7 @@ export class BillingLimitService {
workflows: sharedUsageInfo,
campaigns: sharedUsageInfo,
transactional: sharedUsageInfo,
inbound: sharedUsageInfo,
currency,
};
}
@@ -477,6 +455,7 @@ export class BillingLimitService {
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
currency,
};
} catch (error) {
@@ -501,6 +480,7 @@ export class BillingLimitService {
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
this.getCacheKey(projectId, EmailSourceType.INBOUND),
];
await Promise.all(keys.map(key => redis.del(key)));
@@ -524,11 +504,13 @@ export class BillingLimitService {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
inbound: number | null;
},
newLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
inbound: number | null;
},
): Promise<void> {
try {
@@ -562,6 +544,14 @@ export class BillingLimitService {
);
}
// Check inbound limit
if (oldLimits.inbound !== newLimits.inbound) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
);
}
if (keysToDelete.length > 0) {
await Promise.all(keysToDelete.map(key => redis.del(key)));
signale.debug(
@@ -611,16 +601,20 @@ export class BillingLimitService {
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') {
// Use SETNX (SET if Not eXists) to atomically check and set the flag
// This prevents race conditions where multiple concurrent requests could all pass the check
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
// Email was already sent this month
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
@@ -637,11 +631,6 @@ export class BillingLimitService {
});
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);
}
@@ -664,16 +653,20 @@ export class BillingLimitService {
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') {
// Use SETNX (SET if Not eXists) to atomically check and set the flag
// This prevents race conditions where multiple concurrent requests could all pass the check
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
// Email was already sent this month
return;
}
const members = await prisma.membership.findMany({
where: {projectId},
include: {user: {select: {email: true}}},
});
const emails = members.map(m => m.user.email);
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
@@ -689,11 +682,6 @@ export class BillingLimitService {
});
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);
}
+22 -33
View File
@@ -1,6 +1,7 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -18,32 +19,6 @@ 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
@@ -85,7 +60,7 @@ export class CampaignService {
fromName: data.fromName,
replyTo: data.replyTo,
audienceType: data.audienceType,
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
@@ -133,7 +108,7 @@ export class CampaignService {
if (data.audienceCondition) {
SegmentService.validateCondition(data.audienceCondition);
}
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
}
if (data.segmentId !== undefined) {
@@ -209,7 +184,7 @@ export class CampaignService {
page?: number;
pageSize?: number;
} = {},
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
@@ -232,7 +207,7 @@ export class CampaignService {
]);
return {
campaigns,
data: campaigns,
total,
page,
pageSize,
@@ -476,6 +451,7 @@ export class CampaignService {
const contactData =
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
const variables = {
id: contact.id,
email: contact.email,
...contactData,
data: contactData,
@@ -589,6 +565,7 @@ export class CampaignService {
await prisma.campaign.update({
where: {id: campaignId},
data: {
sentCount: sentEmails,
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
@@ -747,7 +724,7 @@ export class CampaignService {
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: {
const condition = campaign.audienceCondition as unknown as FilterCondition;
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
@@ -783,7 +760,19 @@ export class CampaignService {
throw new HttpException(404, 'Segment not found');
}
const condition = segment.condition as unknown as FilterCondition;
if (segment.type === 'STATIC') {
return {
...baseWhere,
segmentMemberships: {
some: {
segmentId,
exitedAt: null,
},
},
};
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
+67 -80
View File
@@ -1,18 +1,11 @@
import {type Contact, Prisma} from '@plunk/db';
import {isValidLanguageCode} from '@plunk/shared';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {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
@@ -23,7 +16,7 @@ export class ContactService {
limit = 20,
cursor?: string,
search?: string,
): Promise<PaginatedContacts> {
): Promise<CursorPaginatedResponse<Contact>> {
const where: Prisma.ContactWhereInput = {
projectId,
...(search
@@ -58,7 +51,7 @@ export class ContactService {
const total = !cursor ? await prisma.contact.count({where}) : 0;
return {
contacts: results,
data: results,
total,
cursor: nextCursor,
hasMore,
@@ -205,6 +198,7 @@ export class ContactService {
email: string,
data?: Record<string, unknown>,
subscribed?: boolean,
defaultSubscribed: boolean = true,
): Promise<Contact> {
// Find existing contact
const existing = await prisma.contact.findFirst({
@@ -227,21 +221,23 @@ export class ContactService {
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'];
const reservedFields = [
'plunk_id',
'plunk_email',
'id',
'email',
'unsubscribeUrl',
'subscribeUrl',
'manageUrl',
];
if (reservedFields.includes(key)) {
continue;
}
// Validate locale field (special user-settable field)
// Only validate type - any locale string is accepted since we default to English if unsupported
if (key === 'locale') {
if (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) {
if (value !== null && value !== undefined && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
}
@@ -272,7 +268,7 @@ export class ContactService {
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
@@ -292,8 +288,8 @@ export class ContactService {
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
subscribed: subscribed ?? true,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
}
@@ -678,59 +674,11 @@ export class ContactService {
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}> {
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: {
@@ -779,10 +727,7 @@ export class ContactService {
/**
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(
projectId: string,
contactIds: string[],
): Promise<{updated: number}> {
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
@@ -829,10 +774,7 @@ export class ContactService {
/**
* Bulk delete contacts
*/
public static async bulkDelete(
projectId: string,
contactIds: string[],
): Promise<{deleted: number}> {
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({
where: {
id: {in: contactIds},
@@ -843,6 +785,51 @@ export class ContactService {
return {deleted: result.count};
}
/**
* Helper: Check if a field is used in a filter condition (recursive)
*/
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
if (!condition || typeof condition !== 'object') {
return false;
}
// Check groups in the condition
if (Array.isArray(condition.groups)) {
for (const group of condition.groups) {
if (this.fieldUsedInGroup(field, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if a field is used in a filter group (recursive)
*/
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
if (!group || typeof group !== 'object') {
return false;
}
// Check filters in the group
if (Array.isArray(group.filters)) {
for (const filter of group.filters) {
if (filter.field === field) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.fieldUsedInCondition(field, group.conditions);
}
return false;
}
/**
* Track events sequentially to avoid database deadlocks
* Processes events one at a time with error handling
+140 -3
View File
@@ -1,9 +1,19 @@
import React from 'react';
import signale from 'signale';
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {redis, wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
import {
deleteIdentity,
disableFeedbackForwarding,
getDomainVerificationAttributes,
verifyDomain,
} from './SESService.js';
export class DomainService {
/**
@@ -67,6 +77,43 @@ export class DomainService {
const attributes = await getDomainVerificationAttributes(domain.domain);
// If domain failed verification, retry
if (attributes.status === 'Failed') {
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
let attempt = 0;
const maxAttempts = 5;
let success = false;
let delay = 5000;
while (attempt < maxAttempts && !success) {
try {
await verifyDomain(domain.domain);
success = true;
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
} catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string};
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
signale.warn(
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
await new Promise(r => setTimeout(r, delay));
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
throw e;
}
}
}
if (!success) {
signale.error(
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
);
}
}
// Update domain if verification status changed
if (attributes.status === 'Success' && !domain.verified) {
const updatedDomain = await prisma.domain.update({
@@ -79,8 +126,42 @@ export class DomainService {
},
});
// Disable feedback forwarding for verified domain
try {
await disableFeedbackForwarding(domain.domain);
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
} catch (error) {
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
}
// Send notification about domain verified
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
// Send email notification about domain verified
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(domainId);
const ttl = 604800; // 7 days
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainVerifiedEmail, {
projectName: updatedDomain.project.name,
projectId: updatedDomain.project.id,
domain: domain.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
}
}
} catch (emailError) {
signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError);
}
} else if (attributes.status !== 'Success' && domain.verified) {
const updatedDomain = await prisma.domain.update({
where: {id: domainId},
@@ -93,7 +174,41 @@ export class DomainService {
});
// Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
await NtfyService.notifyDomainVerificationFailed(
domain.domain,
updatedDomain.project.name,
updatedDomain.project.id,
);
// Send email notification about domain verification failed
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (wasSet) {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(DomainUnverifiedEmail, {
projectName: updatedDomain.project.name,
projectId: updatedDomain.project.id,
domain: domain.domain,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
}
}
} catch (emailError) {
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
}
}
return {
@@ -184,6 +299,28 @@ export class DomainService {
await prisma.domain.delete({where: {id: domainId}});
// Check if this domain is still attached to another project
const domainExistsElsewhere = await prisma.domain.findFirst({
where: {
domain: domainName,
},
});
// If domain is not used by any other project, remove it from AWS SES
if (!domainExistsElsewhere) {
try {
await deleteIdentity(domainName);
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
} catch (error) {
// Log error but don't fail the domain removal if AWS cleanup fails
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
}
} else {
signale.info(
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
);
}
// Send notification about domain removal
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
+80 -15
View File
@@ -1,11 +1,12 @@
import type {Contact, Email, Prisma, Project} from '@plunk/db';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
@@ -17,6 +18,8 @@ interface Attachment {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}
interface SendEmailParams {
@@ -34,6 +37,7 @@ interface SendEmailParams {
campaignId?: string;
workflowExecutionId?: string;
workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
}
/**
@@ -91,8 +95,8 @@ export class EmailService {
fromName: params.fromName,
toName: params.toName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType: EmailSourceType.TRANSACTIONAL,
templateId: params.templateId,
status: EmailStatus.PENDING,
@@ -152,8 +156,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
campaignId: params.campaignId,
@@ -192,7 +196,8 @@ export class EmailService {
// Check subscription status for marketing emails
// Transactional emails should always be sent regardless of subscription status
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
const contact = await prisma.contact.findUnique({
where: {id: params.contactId},
select: {subscribed: true},
@@ -213,8 +218,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: params.headers ? toPrismaJson(params.headers) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
@@ -241,6 +246,12 @@ export class EmailService {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
}
// If custom recipient email is provided, store it in headers for later use
const emailHeaders = params.headers ? {...params.headers} : {};
if (params.recipientEmail) {
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
}
const email = await prisma.email.create({
data: {
projectId: params.projectId,
@@ -250,8 +261,8 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
replyTo: params.replyTo,
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType,
templateId: params.templateId,
workflowExecutionId: params.workflowExecutionId,
@@ -332,6 +343,7 @@ export class EmailService {
subject: email.subject,
body: email.body,
data: {
id: email.contact.id,
email: email.contact.email,
...contactData,
data: contactData,
@@ -359,10 +371,25 @@ export class EmailService {
? (email.headers as Record<string, string>)
: undefined;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Parse attachments from JSON
const attachments =
email.attachments && Array.isArray(email.attachments)
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
? (email.attachments as Array<{
filename: string;
content: string;
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}>)
: undefined;
// Determine tracking based on project settings and email type
@@ -374,13 +401,13 @@ export class EmailService {
name: fromName,
email: fromEmail,
},
to: [email.contact.email],
to: [recipientEmail],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: customHeaders,
headers: publicHeaders,
attachments: attachments,
tracking: shouldTrack,
});
@@ -502,6 +529,42 @@ export class EmailService {
data: updateData,
});
// Update campaign stats if applicable
if (email.campaignId) {
const campaignUpdate: Prisma.CampaignUpdateInput = {};
switch (eventType) {
case 'delivered':
campaignUpdate.deliveredCount = {increment: 1};
break;
case 'opened':
// Only increment unique opens to match getStats logic
if (!email.openedAt) {
campaignUpdate.openedCount = {increment: 1};
}
break;
case 'clicked':
// Only increment unique clicks to match getStats logic
if (!email.clickedAt) {
campaignUpdate.clickedCount = {increment: 1};
}
break;
case 'bounced':
campaignUpdate.bouncedCount = {increment: 1};
break;
}
if (Object.keys(campaignUpdate).length > 0) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: campaignUpdate,
});
}
}
// Track event
await prisma.event.create({
data: {
@@ -509,7 +572,7 @@ export class EmailService {
contactId: email.contactId,
emailId: email.id,
name: `email.${eventType}`,
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
data: metadata ? toPrismaJson(metadata) : undefined,
},
});
}
@@ -530,10 +593,11 @@ export class EmailService {
: {}),
};
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
prisma.email.count({where}),
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
@@ -544,6 +608,7 @@ export class EmailService {
total,
sent,
delivered,
received,
opened,
clicked,
bounced,
+187 -82
View File
@@ -1,26 +1,154 @@
import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker';
import type {EmailVerificationResult} from '@plunk/types';
import {redis} from '../database/redis.js';
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 PERSONAL_DOMAINS_URL =
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
// Known email forwarding/alias services
const FORWARDING_DOMAINS = new Set([
'privaterelay.appleid.com', // Apple Sign In
'mozmail.com', // Firefox Relay
'simplelogin.com', // SimpleLogin
'simplelogin.fr',
'simplelogin.co',
'simplelogin.io',
'aleeas.com',
'slmail.me',
'dralias.com',
'8shield.net',
'anonaddy.com', // Addy.io
'anonaddy.me',
'addy.io',
'duck.com', // DuckDuckGo
'33mail.com', // 33mail
'33m.co',
'passmail.com', // Proton Pass
'passmail.net',
'passinbox.com',
'passfwd.com',
'y.yo.fr',
'opayq.com', // IronVest (formerly Blur)
'cloak.id', // Cloaked
'erine.email', // Erine
'use.startmail.com', // StartMail
]);
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null;
private static personalDomainsSet: Set<string> | null = null;
/**
* Verify an email address
* - Checks for NS records (proves domain exists in DNS)
* - Checks for MX records (required for receiving email)
* - Checks for A/AAAA records (informational - indicates if domain has a website)
* - Detects disposable email addresses
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
* - Detects forwarding/alias email addresses
* - Suggests corrections for common typos
*/
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
const result: EmailVerificationResult = {
email,
valid: true,
isDisposable: false,
isAlias: false,
isTypo: false,
isPlusAddressed: false,
isPersonalEmail: false,
domainExists: false,
hasWebsite: false,
hasMxRecords: false,
reasons: [],
};
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
result.valid = false;
result.reasons.push('Invalid email format');
return result;
}
const domain = emailParts[1]!; // Safe to assert, we already validated length
// Check if email is from a disposable domain using GitHub list
result.isDisposable = await this.isDisposableDomain(domain);
// Check if email is from a personal/free email provider
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
// Check if email is from a known forwarding/alias service
result.isAlias = this.isForwardingDomain(domain);
// Check for plus addressing
result.isPlusAddressed = emailParts[0]!.includes('+');
// Check for common typos and suggest corrections
const typoCheck = run({email});
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
result.suggestedEmail = typoCheck.full;
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
result.isTypo = true;
}
// Step 1: Check NS records - proves the domain exists in DNS
try {
const nsRecords = await dns.resolveNs(domain);
result.domainExists = nsRecords && nsRecords.length > 0;
} catch {
result.domainExists = false;
result.valid = false;
result.reasons.push('Domain does not exist (no nameservers found)');
// If domain doesn't exist, no point checking MX/A records
return result;
}
// Step 2: Check MX records - required for receiving email
try {
const mxRecords = await dns.resolveMx(domain);
result.hasMxRecords = mxRecords && mxRecords.length > 0;
if (!result.hasMxRecords) {
result.valid = false;
result.reasons.push('Domain cannot receive email (no MX records found)');
}
} catch {
result.hasMxRecords = false;
result.valid = false;
result.reasons.push('Domain cannot receive email (no MX records found)');
}
// Step 3: Check if domain has A/AAAA records - informational only
// This indicates if the domain has a website/web server
// Doesn't affect email validity since email only requires MX records
try {
await dns.resolve(domain, 'A');
result.hasWebsite = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.hasWebsite = true;
} catch {
// Domain doesn't have A/AAAA records (no website), but this is OK for email
result.hasWebsite = false;
}
}
// If no issues were found, add a success reason
if (result.valid && result.reasons.length === 0) {
result.reasons.push('Email appears to be valid');
}
return result;
}
/**
* Fetch and cache the disposable domains list from GitHub
@@ -76,85 +204,62 @@ export class EmailVerificationService {
}
/**
* 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
* Check if a domain is a known forwarding/alias service
*/
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
const result: EmailVerificationResult = {
email,
valid: true,
isDisposable: false,
isTypo: false,
isPlusAddressed: false,
domainExists: false,
hasMxRecords: false,
reasons: [],
};
private static isForwardingDomain(domain: string): boolean {
return FORWARDING_DOMAINS.has(domain.toLowerCase());
}
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
result.valid = false;
result.reasons.push('Invalid email format');
return result;
/**
* Fetch and cache the personal email domains list from GitHub
* Uses Redis for caching with 24-hour TTL
* Falls back to in-memory cache if Redis fails
*/
private static async getPersonalEmailDomains(): Promise<Set<string>> {
// Return in-memory cache if available
if (this.personalDomainsSet) {
return this.personalDomainsSet;
}
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');
// Try to get from Redis cache first
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
if (cached) {
const domains = JSON.parse(cached) as string[];
this.personalDomainsSet = new Set(domains);
return this.personalDomainsSet;
}
}
// 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');
// Fetch from GitHub if not in cache
const response = await fetch(PERSONAL_DOMAINS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
}
}
// If no issues were found, add a success reason
if (result.valid && result.reasons.length === 0) {
result.reasons.push('Email appears to be valid');
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
return result;
// Cache in Redis
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.personalDomainsSet = new Set(domains);
return this.personalDomainsSet;
} catch (error) {
console.error('Error fetching personal email domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check if a domain is a personal/free email provider
*/
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
const personalDomains = await this.getPersonalEmailDomains();
return personalDomains.has(domain.toLowerCase());
}
}
+3 -2
View File
@@ -1,6 +1,7 @@
import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -32,7 +33,7 @@ export class EventService {
contactId,
emailId,
name: eventName,
data: data ? (data as Prisma.InputJsonValue) : undefined,
data: data ? toPrismaJson(data) : undefined,
},
});
@@ -472,7 +473,7 @@ export class EventService {
contactId,
status: 'RUNNING',
currentStepId: triggerStep.id,
context: context ? (context as Prisma.InputJsonValue) : undefined,
context: context ? toPrismaJson(context) : undefined,
},
});
+368
View File
@@ -0,0 +1,368 @@
import type {Membership} from '@plunk/db';
import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js';
import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
/**
* Service for managing project memberships
* Centralizes all membership-related database queries with caching
*/
export class MembershipService {
// ============================================
// AUTHORIZATION METHODS (Cached)
// ============================================
/**
* Check if user has any access to a project (any role)
* CACHED (1 min TTL) - called on every authenticated request
*/
public static async hasAccess(userId: string, projectId: string): Promise<boolean> {
return wrapRedis(
Keys.Membership.access(userId, projectId),
async () => {
const membership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
return membership !== null;
},
REDIS_ONE_MINUTE,
);
}
/**
* Check if user has admin or owner access to a project
* CACHED (1 min TTL) - called before write operations
*/
public static async hasAdminAccess(userId: string, projectId: string): Promise<boolean> {
return wrapRedis(
Keys.Membership.admin(userId, projectId),
async () => {
const membership = await prisma.membership.findFirst({
where: {
userId,
projectId,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
return membership !== null;
},
REDIS_ONE_MINUTE,
);
}
/**
* Get user's membership with role info
* CACHED (1 min TTL) - returns full membership or null
*/
public static async getMembership(userId: string, projectId: string): Promise<Membership | null> {
return wrapRedis(
Keys.Membership.full(userId, projectId),
async () => {
return prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
},
REDIS_ONE_MINUTE,
);
}
/**
* Require membership or throw 404
* Uses cached getMembership internally
*/
public static async requireAccess(userId: string, projectId: string): Promise<Membership> {
const membership = await this.getMembership(userId, projectId);
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
return membership;
}
/**
* Require admin/owner access or throw 403
* Uses cached hasAdminAccess internally
*/
public static async requireAdminAccess(userId: string, projectId: string): Promise<Membership> {
const membership = await this.getMembership(userId, projectId);
if (!membership) {
throw new HttpException(404, 'Project not found or you do not have access');
}
if (membership.role !== 'ADMIN' && membership.role !== 'OWNER') {
throw new HttpException(403, 'Insufficient permissions. Admin or owner access required.');
}
return membership;
}
// ============================================
// MEMBER LISTING (Not Cached - Dynamic Data)
// ============================================
/**
* Get all members of a project with user info
* NOT CACHED - returns fresh data for member management UI
*/
public static async getMembers(projectId: string): Promise<MemberWithEmail[]> {
const memberships = await prisma.membership.findMany({
where: {
projectId,
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
});
return memberships.map(m => ({
userId: m.userId,
email: m.user.email,
role: m.role,
createdAt: m.createdAt,
}));
}
/**
* Get project owner
* CACHED (5 min TTL) - owner rarely changes
*/
public static async getOwner(projectId: string): Promise<OwnerInfo> {
return wrapRedis(
Keys.Membership.owner(projectId),
async () => {
const ownerMembership = await prisma.membership.findFirst({
where: {
projectId,
role: 'OWNER',
},
include: {
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!ownerMembership) {
throw new HttpException(404, 'Project owner not found');
}
return {
userId: ownerMembership.userId,
email: ownerMembership.user.email,
};
},
FIVE_MINUTES_IN_SECONDS,
);
}
// ============================================
// CRUD OPERATIONS (Invalidate Cache)
// ============================================
/**
* Add a member to a project
* Invalidates cache for the project
*/
public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// Check if membership already exists
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (existingMembership) {
throw new HttpException(409, 'User is already a member of this project');
}
// Create new membership
const newMembership = await prisma.membership.create({
data: {
userId,
projectId,
role,
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
return newMembership;
}
/**
* Update a member's role
* Throws if trying to change OWNER role
* Invalidates cache
*/
public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
// Get existing membership
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (!existingMembership) {
throw new HttpException(404, 'Membership not found');
}
// Prevent changing OWNER role
if (existingMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot change the role of the project owner');
}
// Update role
const updatedMembership = await prisma.membership.update({
where: {
userId_projectId: {
userId,
projectId,
},
},
data: {
role: newRole,
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
return updatedMembership;
}
/**
* Remove a member from a project
* Throws if trying to remove OWNER
* Invalidates cache
*/
public static async removeMember(projectId: string, userId: string): Promise<void> {
// Get existing membership
const existingMembership = await prisma.membership.findUnique({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
if (!existingMembership) {
throw new HttpException(404, 'Membership not found');
}
// Prevent removing OWNER
if (existingMembership.role === 'OWNER') {
throw new HttpException(403, 'Cannot remove the project owner');
}
// Delete membership
await prisma.membership.delete({
where: {
userId_projectId: {
userId,
projectId,
},
},
});
// Invalidate cache
await this.invalidateCache(projectId, userId);
}
// ============================================
// UTILITY METHODS
// ============================================
/**
* Check if user is member of any disabled project
* NOT CACHED - security-critical check
*/
public static async userHasDisabledProject(userId: string): Promise<DisabledProjectInfo> {
const disabledMemberships = await prisma.membership.findMany({
where: {
userId,
project: {
disabled: true,
},
},
include: {
project: {
select: {
name: true,
},
},
},
});
return {
hasDisabledProject: disabledMemberships.length > 0,
disabledProjectNames: disabledMemberships.map(m => m.project.name),
};
}
// ============================================
// PRIVATE CACHE MANAGEMENT
// ============================================
/**
* Invalidate all caches for a project and user
* Called after membership changes
*/
private static async invalidateCache(projectId: string, userId?: string): Promise<void> {
const keysToDelete: string[] = [];
if (userId) {
// Invalidate user-specific caches
keysToDelete.push(
Keys.Membership.access(userId, projectId),
Keys.Membership.admin(userId, projectId),
Keys.Membership.full(userId, projectId),
);
}
// Invalidate project-wide caches
keysToDelete.push(Keys.Membership.owner(projectId));
// Delete all keys
if (keysToDelete.length > 0) {
await redis.del(...keysToDelete);
}
}
}
+105 -76
View File
@@ -1,40 +1,6 @@
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
import signale from 'signale';
/**
* Priority levels for ntfy notifications
* Based on ntfy.sh documentation
*/
export enum NtfyPriority {
MIN = 1, // No vibration/sound, relegated to "Other notifications"
LOW = 2, // No vibration/sound, hidden until drawer opened
DEFAULT = 3, // Short vibration and sound (standard)
HIGH = 4, // Long vibration, pop-over notification
MAX = 5, // Long vibration bursts, pop-over notification
}
/**
* Tags for ntfy notifications (emoji shortcuts)
*/
export enum NtfyTag {
WARNING = 'warning',
ERROR = 'rotating_light',
SUCCESS = 'white_check_mark',
MONEY = 'money_with_wings',
SHIELD = 'shield',
ROCKET = 'rocket',
BELL = 'bell',
CHART = 'chart_with_upwards_trend',
SKULL = 'skull',
INFO = 'information_source',
}
export interface NtfyNotification {
title: string;
message: string;
priority?: NtfyPriority;
tags?: NtfyTag[];
}
/**
* Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable
@@ -241,6 +207,18 @@ export class NtfyService {
* Notify about security warning (non-critical)
*/
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:security:warning:${projectId}`;
const ttl = 3600; // 1 hour
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
const warningText = warnings.join(', ');
await this.sendDefault(
'Security Warning',
@@ -251,7 +229,7 @@ export class NtfyService {
/**
* Notify about email bounce - LOW priority (high volume)
* Rate-limited to only send notification every 20 bounces with per-project breakdown
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
*/
public static async notifyEmailBounce(
projectName: string,
@@ -262,63 +240,51 @@ export class NtfyService {
// 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
// Use Redis counters to track bounce count globally
const globalCountKey = `ntfy:bounce:count`;
const projectCountKey = `ntfy:bounce:count:${projectId}`;
const projectListKey = `ntfy:bounce:projects`;
const bounceListKey = `ntfy:bounce:latest`;
// 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);
// Store this bounce event in a list (keep latest 20)
const bounceEvent = JSON.stringify({
projectName,
projectId,
recipientEmail,
bounceType: bounceType || 'Unknown',
timestamp: new Date().toISOString(),
});
await redis.lpush(bounceListKey, bounceEvent);
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
// Set expiry on first increment (24 hour rolling window)
if (globalCount === 1) {
await redis.expire(globalCountKey, 86400);
await redis.expire(projectListKey, 86400);
await redis.expire(bounceListKey, 86400);
}
// Always refresh project counter expiry to match global window
await redis.expire(projectCountKey, 86400);
// Only send notification every 20 bounces
if (globalCount % 20 === 0) {
// Get all projects with bounces
const projectIds = await redis.smembers(projectListKey);
// Get the latest 20 bounces
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
// 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},
});
// Parse and format the bounce events
const bounceDetails = latestBounces
.map(bounce => {
try {
const parsed = JSON.parse(bounce);
return `${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
} catch {
return null;
}
})
.filter(Boolean)
.join('\n');
projectCounts.push({
projectId: pid,
count: parseInt(count, 10),
name: project?.name || 'Unknown',
});
}
}
// Sort by count descending
projectCounts.sort((a, b) => b.count - a.count);
// Build breakdown message
const breakdown = projectCounts.map(p => `${p.name} (${p.projectId}): ${p.count}`).join('\n');
const bounceInfo = bounceType ? ` (${bounceType})` : '';
await this.send({
title: 'Email Bounces',
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
@@ -352,6 +318,19 @@ export class NtfyService {
});
}
/**
* Notify about failed signup attempt with invalid email - LOW priority
*/
public static async notifyFailedSignupAttempt(email: string, reasons: string[]): Promise<void> {
const reasonText = reasons.join(', ');
await this.send({
title: 'Failed Signup - Invalid Email',
message: `Signup attempt blocked for email: ${email}\nReasons: ${reasonText}`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING, NtfyTag.SHIELD],
});
}
/**
* Notify about new user account created via OAuth - LOW priority
*/
@@ -610,6 +589,18 @@ export class NtfyService {
percentage: number,
sourceType: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
const ttl = 86400; // 24 hours
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
await this.sendDefault(
'Billing Limit Warning',
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
@@ -629,6 +620,18 @@ export class NtfyService {
limit: number,
sourceType: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
const ttl = 86400; // 24 hours
// Use SETNX to atomically check and set the flag (prevents race conditions)
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
if (!wasSet) {
return;
}
await this.sendUrgent(
'Billing Limit Exceeded',
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
@@ -747,6 +750,32 @@ export class NtfyService {
});
}
/**
* Notify about bundled segment membership updates - LOW priority
* Used when multiple segments are updated in a single processing cycle
*/
public static async notifySegmentMembershipBundled(
projectName: string,
projectId: string,
segmentCount: number,
totalAdded: number,
totalRemoved: number,
): Promise<void> {
const changes: string[] = [];
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
await this.send({
title: 'Segment Memberships Updated',
message,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/**
* Notify about segment deleted - MIN priority
*/
+31
View File
@@ -0,0 +1,31 @@
import {Keys} from './keys.js';
import {wrapRedis} from '../database/redis.js';
import {prisma} from '../database/prisma.js';
export class ProjectService {
public static async id(id: string) {
return wrapRedis(Keys.Project.id(id), async () => {
return prisma.project.findUnique({where: {id}});
});
}
public static async secret(key: string) {
return wrapRedis(Keys.Project.secret(key), async () => {
return prisma.project.findUnique({
where: {
secret: key,
},
});
});
}
public static async public(key: string) {
return wrapRedis(Keys.Project.public(key), async () => {
return prisma.project.findUnique({
where: {
public: key,
},
});
});
}
}
+29 -55
View File
@@ -1,63 +1,21 @@
import {type Job, Queue} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import type {
ApiRequestCleanupJobData,
BulkContactActionJobData,
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
ScheduledCampaignJobData,
SegmentCountJobData,
SendEmailJobData,
WorkflowStepJobData,
} from '@plunk/types';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
/**
* Queue Job Data Types
*/
export interface SendEmailJobData {
emailId: string;
}
export interface CampaignBatchJobData {
campaignId: string;
batchNumber: number;
offset: number;
limit: number;
cursor?: string; // For cursor-based pagination
}
export interface WorkflowStepJobData {
executionId: string;
stepId: string;
type?: 'process-step' | 'timeout'; // Job type for different handling
stepExecutionId?: string; // For timeout jobs, reference to the step execution
}
export interface ScheduledCampaignJobData {
campaignId: string;
}
export interface ContactImportJobData {
projectId: string;
csvData: string; // Base64 encoded CSV content
filename: string;
}
export interface SegmentCountJobData {
projectId?: string; // Optional: if provided, only update this project's segments
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DomainVerificationJobData {
// Empty for now - processes all domains
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ApiRequestCleanupJobData {
// Empty - cleans up old API request logs
}
export interface BulkContactActionJobData {
projectId: string;
contactIds: string[];
operation: 'subscribe' | 'unsubscribe' | 'delete';
}
/**
* Queue Configuration
*/
@@ -324,14 +282,22 @@ export class QueueService {
/**
* Get import job status and progress
* @param jobId - The job ID
* @param projectId - The project ID to verify authorization
* @returns Job status or null if not found or unauthorized
*/
public static async getImportJobStatus(jobId: string) {
public static async getImportJobStatus(jobId: string, projectId: string) {
const job = await importQueue.getJob(jobId);
if (!job) {
return null;
}
// Security: Verify that the job belongs to the requesting project
if (job.data.projectId !== projectId) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
@@ -366,14 +332,22 @@ export class QueueService {
/**
* Get bulk action job status and progress
* @param jobId - The job ID
* @param projectId - The project ID to verify authorization
* @returns Job status or null if not found or unauthorized
*/
public static async getBulkActionJobStatus(jobId: string) {
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
const job = await bulkContactQueue.getJob(jobId);
if (!job) {
return null;
}
// Security: Verify that the job belongs to the requesting project
if (job.data.projectId !== projectId) {
return null;
}
const state = await job.getState();
const progress = job.progress;
const returnValue = job.returnvalue;
+5 -6
View File
@@ -1,21 +1,21 @@
import {
S3Client,
PutObjectCommand,
CreateBucketCommand,
HeadBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import crypto from 'crypto';
import signale from 'signale';
import {
S3_ENDPOINT,
S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET,
S3_BUCKET,
S3_PUBLIC_URL,
S3_FORCE_PATH_STYLE,
S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PUBLIC_URL,
} from '../app/constants.js';
/**
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
let bucketExists = true;
try {
// Check if bucket exists
await s3Client.send(
new HeadBucketCommand({
Bucket: S3_BUCKET,
+74 -24
View File
@@ -8,7 +8,7 @@ import {
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
TRACKING_TOGGLE_ENABLED,
} from '../app/constants.js';
/**
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}[]
| null;
tracking?: boolean;
@@ -101,8 +103,13 @@ export async function sendRawEmail({
}
// 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;
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
: null;
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
: null;
// Format To header with names if provided
const toHeader = to
@@ -118,17 +125,21 @@ export async function sendRawEmail({
// Extract just email addresses for Destinations (SES requirement)
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
// Determine root content type
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
if (mixedBoundary) {
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
} else if (relatedBoundary) {
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
}
// Build raw MIME message
const rawMessage = `From: ${from.name} <${from.email}>
let 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}"`
}
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
@@ -138,29 +149,61 @@ ${
}
${unsubscribeHeader}
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
}--${boundary}
`;
// building the body
if (mixedBoundary) {
rawMessage += `--${mixedBoundary}\n`;
if (relatedBoundary) {
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
rawMessage += `--${relatedBoundary}\n`;
}
} else if (relatedBoundary) {
rawMessage += `--${relatedBoundary}\n`;
}
// If we are nested, we need to specify that this next part is the alternative container
if (mixedBoundary || relatedBoundary) {
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
}
// The alternative part content (always contains HTML)
rawMessage += `--${altBoundary}
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
${breakLongLines(content.html, 500)}
--${boundary}--
${
attachments && attachments.length > 0
? '\n' +
attachments
.map(
attachment => `--${mixedBoundary}
--${altBoundary}--
`;
// Add inline attachments to the related container
if (relatedBoundary) {
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
for (const attachment of inlineAttachments) {
rawMessage += `\n--${relatedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-ID: <${attachment.contentId || attachment.filename}>
Content-Disposition: inline; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`;
}
rawMessage += `\n--${relatedBoundary}--`;
}
// Add regular attachments to the mixed container
if (mixedBoundary) {
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
for (const attachment of regularAttachments) {
rawMessage += `\n--${mixedBoundary}
Content-Type: ${attachment.contentType}
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="${attachment.filename}"
${breakLongLines(attachment.content, 76, true)}`,
)
.join('\n')
: ''
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
${breakLongLines(attachment.content, 76, true)}`;
}
rawMessage += `\n--${mixedBoundary}--`;
}
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
@@ -258,6 +301,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
});
};
/**
* Delete a verified domain identity from AWS SES
*/
export const deleteIdentity = async (domain: string): Promise<void> => {
await ses.deleteIdentity({Identity: domain});
};
/**
* Get AWS SES account sending quota and rate limit
* @returns MaxSendRate (emails per second) or null if the call fails
+85 -67
View File
@@ -5,6 +5,7 @@ import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
@@ -18,16 +19,23 @@ const SECURITY_THRESHOLDS = {
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
BOUNCE_7DAY_WARNING: 3,
BOUNCE_7DAY_CRITICAL: 8,
BOUNCE_ALLTIME_WARNING: 2,
BOUNCE_ALLTIME_CRITICAL: 5,
BOUNCE_7DAY_WARNING: 5,
BOUNCE_7DAY_CRITICAL: 10,
BOUNCE_ALLTIME_WARNING: 4,
BOUNCE_ALLTIME_CRITICAL: 8,
// Complaint rate thresholds (spam reports)
COMPLAINT_7DAY_WARNING: 0.05,
COMPLAINT_7DAY_CRITICAL: 0.1,
COMPLAINT_ALLTIME_WARNING: 0.02,
COMPLAINT_ALLTIME_CRITICAL: 0.08,
COMPLAINT_7DAY_WARNING: 0.075,
COMPLAINT_7DAY_CRITICAL: 0.15,
COMPLAINT_ALLTIME_WARNING: 0.03,
COMPLAINT_ALLTIME_CRITICAL: 0.12,
// Minimum absolute counts (prevents small sample size false positives)
// Both percentage AND absolute count must be exceeded to trigger
MIN_BOUNCES_FOR_CRITICAL: 10,
MIN_BOUNCES_FOR_WARNING: 5,
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
} as const;
interface RateData {
@@ -177,26 +185,7 @@ export class SecurityService {
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),
};
return MembershipService.userHasDisabledProject(userId);
}
/**
@@ -293,50 +282,82 @@ export class SecurityService {
const warnings: string[] = [];
// Only enforce if minimum emails threshold is met
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
if (hasMinimumVolume) {
// Check 7-day bounce rate
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
// Check 7-day bounce rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
);
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
} else if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push(
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
);
}
}
// Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
);
} else if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push(
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
);
}
}
// Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate - requires BOTH rate AND absolute count
if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
);
} else if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
);
}
// Check all-time bounce rate
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
// Check all-time complaint rate - requires BOTH rate AND absolute count
if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
violations.push(
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
);
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
} else if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push(
`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}%)`,
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
);
}
}
@@ -408,11 +429,8 @@ export class SecurityService {
// 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);
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
+330 -58
View File
@@ -1,5 +1,6 @@
import {type Contact, Prisma, type Segment} from '@plunk/db';
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -8,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
export interface PaginatedContacts {
contacts: Contact[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
/**
* Convert segment name to a URL-safe slug for event names
* Example: "VIP Customers" -> "vip-customers"
@@ -72,13 +64,35 @@ export class SegmentService {
segmentId: string,
page = 1,
pageSize = 20,
): Promise<PaginatedContacts> {
): Promise<PaginatedResponse<Contact>> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
const skip = (page - 1) * pageSize;
if (segment.type === 'STATIC') {
// For static segments, query via SegmentMembership records
const [memberships, total] = await Promise.all([
prisma.segmentMembership.findMany({
where: {segmentId, exitedAt: null},
include: {contact: true},
skip,
take: pageSize,
orderBy: {enteredAt: 'desc'},
}),
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
]);
return {
data: memberships.map(m => m.contact),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
const [contacts, total] = await Promise.all([
prisma.contact.findMany({
where,
@@ -90,7 +104,7 @@ export class SegmentService {
]);
return {
contacts,
data: contacts,
total,
page,
pageSize,
@@ -106,23 +120,35 @@ export class SegmentService {
data: {
name: string;
description?: string;
condition: FilterCondition;
type?: SegmentType;
condition?: FilterCondition;
trackMembership?: boolean;
},
): Promise<Segment> {
// Validate condition
this.validateCondition(data.condition);
const segmentType = data.type ?? 'DYNAMIC';
let memberCount = 0;
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
// Compute initial member count
const where = this.buildWhereClause(projectId, data.condition);
const memberCount = await prisma.contact.count({where});
if (segmentType === 'DYNAMIC') {
if (!data.condition) {
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
}
// Validate condition
this.validateCondition(data.condition);
// Compute initial member count
const where = this.buildWhereClause(projectId, data.condition);
memberCount = await prisma.contact.count({where});
conditionJson = toPrismaJson(data.condition);
}
const segment = await prisma.segment.create({
data: {
projectId,
name: data.name,
description: data.description,
condition: data.condition as unknown as Prisma.InputJsonValue,
type: segmentType,
condition: conditionJson,
trackMembership: data.trackMembership ?? false,
memberCount,
},
@@ -153,12 +179,7 @@ export class SegmentService {
},
): 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 existing = await this.get(projectId, segmentId);
const updateData: Prisma.SegmentUpdateInput = {};
@@ -168,8 +189,10 @@ export class SegmentService {
if (data.description !== undefined) {
updateData.description = data.description;
}
if (data.condition !== undefined) {
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
if (data.condition !== undefined && existing.type !== 'STATIC') {
// Validate condition if provided (only for DYNAMIC segments)
this.validateCondition(data.condition);
updateData.condition = toPrismaJson(data.condition);
// Recompute member count when condition changes
const where = this.buildWhereClause(projectId, data.condition);
@@ -237,10 +260,16 @@ export class SegmentService {
*/
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
const segment = await this.get(projectId, segmentId);
const condition = segment.condition as unknown as FilterCondition;
const where = this.buildWhereClause(projectId, condition);
const memberCount = await prisma.contact.count({where});
let memberCount: number;
if (segment.type === 'STATIC') {
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
await prisma.segment.update({
where: {id: segmentId},
@@ -257,7 +286,7 @@ export class SegmentService {
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, condition: true},
select: {id: true, type: true, condition: true},
});
// Process in batches to avoid overwhelming the database
@@ -268,9 +297,17 @@ export class SegmentService {
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});
let memberCount: number;
if (segment.type === 'STATIC') {
memberCount = await prisma.segmentMembership.count({
where: {segmentId: segment.id, exitedAt: null},
});
} else {
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
memberCount = await prisma.contact.count({where});
}
await prisma.segment.update({
where: {id: segment.id},
@@ -284,6 +321,104 @@ export class SegmentService {
}
}
/**
* Add contacts to a static segment by email
*/
public static async addContacts(
projectId: string,
segmentId: string,
emails: string[],
): Promise<{added: number; notFound: string[]}> {
const segment = await this.get(projectId, segmentId);
if (segment.type !== 'STATIC') {
throw new HttpException(400, 'Can only add contacts to STATIC segments');
}
// Look up contacts by email (case-insensitive)
const contacts = await prisma.contact.findMany({
where: {
projectId,
email: {in: emails, mode: 'insensitive'},
},
select: {id: true, email: true},
});
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
if (contacts.length > 0) {
// Check for existing memberships (to reactivate vs create new)
const existingMemberships = await prisma.segmentMembership.findMany({
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
select: {contactId: true},
});
const existingIds = new Set(existingMemberships.map(m => m.contactId));
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
if (newContactIds.length > 0) {
await prisma.segmentMembership.createMany({
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
skipDuplicates: true,
});
}
if (reEntryIds.length > 0) {
await prisma.segmentMembership.updateMany({
where: {segmentId, contactId: {in: reEntryIds}},
data: {exitedAt: null, enteredAt: new Date()},
});
}
// Update member count
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
}
return {added: contacts.length, notFound};
}
/**
* Remove contacts from a static segment by email
*/
public static async removeContacts(
projectId: string,
segmentId: string,
emails: string[],
): Promise<{removed: number}> {
const segment = await this.get(projectId, segmentId);
if (segment.type !== 'STATIC') {
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
}
// Look up contacts by email
const contacts = await prisma.contact.findMany({
where: {
projectId,
email: {in: emails, mode: 'insensitive'},
},
select: {id: true},
});
if (contacts.length > 0) {
const contactIds = contacts.map(c => c.id);
await prisma.segmentMembership.updateMany({
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
data: {exitedAt: new Date()},
});
// Update member count
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
}
return {removed: contacts.length};
}
/**
* Compute or recompute segment membership for all contacts
* Now uses cursor-based pagination for memory efficiency with large contact lists
@@ -298,7 +433,14 @@ export class SegmentService {
throw new HttpException(400, 'Segment does not have membership tracking enabled');
}
const condition = segment.condition as unknown as FilterCondition;
if (segment.type === 'STATIC') {
// For static segments, just update the count from memberships — no contact scanning
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
return {added: 0, removed: 0, total};
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const where = this.buildWhereClause(projectId, condition);
// Get all matching contacts using cursor-based pagination to avoid memory issues
@@ -364,15 +506,43 @@ export class SegmentService {
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
await prisma.segmentMembership.createMany({
data: batch.map(contactId => ({
// Check which contacts already have a membership record (inactive)
const existingMemberships = await prisma.segmentMembership.findMany({
where: {
segmentId,
contactId,
enteredAt: new Date(),
})),
skipDuplicates: true,
contactId: {in: batch},
},
select: {contactId: true},
});
const existingContactIds = new Set(existingMemberships.map(m => m.contactId));
const newEntries = batch.filter(id => !existingContactIds.has(id));
const reEntries = batch.filter(id => existingContactIds.has(id));
if (newEntries.length > 0) {
await prisma.segmentMembership.createMany({
data: newEntries.map(contactId => ({
segmentId,
contactId,
enteredAt: new Date(),
})),
skipDuplicates: true,
});
}
if (reEntries.length > 0) {
await prisma.segmentMembership.updateMany({
where: {
segmentId,
contactId: {in: reEntries},
},
data: {
exitedAt: null,
enteredAt: new Date(),
},
});
}
// Create segment-specific entry events for each contact in the batch
for (const contactId of batch) {
try {
@@ -496,6 +666,19 @@ export class SegmentService {
}
}
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
if (condition.logic === 'AND') {
return {AND: groupClauses};
} else {
return {OR: groupClauses};
}
}
/**
* Validate filter group (recursive)
*/
@@ -551,8 +734,10 @@ export class SegmentService {
'exists',
'notExists',
'within',
'olderThan',
'triggered',
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
];
@@ -571,7 +756,9 @@ export class SegmentService {
'greaterThanOrEqual',
'lessThanOrEqual',
'within',
'olderThan',
'triggeredWithin',
'triggeredOlderThan',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
@@ -579,7 +766,7 @@ export class SegmentService {
}
// Validate unit for time-based operators
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
}
}
@@ -594,19 +781,6 @@ export class SegmentService {
};
}
/**
* Build Prisma clause from filter condition (recursive)
*/
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
if (condition.logic === 'AND') {
return {AND: groupClauses};
} else {
return {OR: groupClauses};
}
}
/**
* Build Prisma clause from filter group (recursive)
*/
@@ -709,6 +883,20 @@ export class SegmentService {
// Use ISO string for lexicographic comparison in JSON
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
}
case 'olderThan': {
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
if (!unit) {
throw new HttpException(400, 'Unit is required for "olderThan" operator');
}
// Calculate the "before" date (X time units ago from now)
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
// Use ISO string for lexicographic comparison in JSON
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
}
default:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
}
@@ -802,6 +990,18 @@ export class SegmentService {
return {[field]: {gte: since}};
}
case 'olderThan': {
// "olderThan X days/hours/minutes" means more than X time units ago
if (!unit) {
throw new HttpException(400, 'Unit is required for "olderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {[field]: {lt: before}};
}
default:
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
}
@@ -901,6 +1101,42 @@ export class SegmentService {
};
}
case 'triggeredOlderThan': {
// Contact triggered this event, but only more than X time ago (not recently)
// This means: has event AND all occurrences are before the cutoff
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {
AND: [
// Must have triggered the event at some point
{
events: {
some: {
name: eventName,
},
},
},
// But NOT within the recent timeframe
{
events: {
none: {
name: eventName,
createdAt: {
gte: before,
},
},
},
},
],
};
}
case 'notTriggered':
// Contact has never triggered this event
return {
@@ -975,6 +1211,42 @@ export class SegmentService {
};
}
case 'triggeredOlderThan': {
// Contact had this email activity, but only more than X time ago (not recently)
if (!unit) {
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
}
const now = new Date();
const milliseconds = this.getMilliseconds(value as number, unit);
const before = new Date(now.getTime() - milliseconds);
return {
AND: [
// Must have the email activity at some point
{
emails: {
some: {
[field]: {
not: null,
},
},
},
},
// But NOT within the recent timeframe
{
emails: {
none: {
[field]: {
gte: before,
},
},
},
},
],
};
}
case 'notTriggered':
// Contact has never had this email activity
return {
+3 -10
View File
@@ -1,18 +1,11 @@
import type {Template} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
export interface PaginatedTemplates {
templates: Template[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export class TemplateService {
/**
* Get all templates for a project with pagination
@@ -23,7 +16,7 @@ export class TemplateService {
pageSize = 20,
search?: string,
type?: Template['type'],
): Promise<PaginatedTemplates> {
): Promise<PaginatedResponse<Template>> {
const skip = (page - 1) * pageSize;
const where: Prisma.TemplateWhereInput = {
@@ -51,7 +44,7 @@ export class TemplateService {
]);
return {
templates,
data: templates,
total,
page,
pageSize,
+7 -2
View File
@@ -10,6 +10,7 @@ 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"
* e.g., "http://app.plunk.local" -> ".plunk.local"
*/
function getCookieDomain(): string | undefined {
if (NODE_ENV === 'development') {
@@ -28,10 +29,14 @@ function getCookieDomain(): string | 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')) {
// For *.localhost, use .localhost (reserved TLD)
if (hostname.endsWith('.localhost')) {
return '.localhost';
}
// For *.local (mDNS TLD), use the actual base domain
if (hostname.endsWith('.local')) {
return `.${parts.slice(-2).join('.')}`;
}
// For other domains, use the last two parts (e.g., .example.com)
return `.${parts.slice(-2).join('.')}`;
}
@@ -1,14 +1,15 @@
import type {
Contact,
Prisma,
Template,
Workflow,
WorkflowExecution,
WorkflowStep,
WorkflowStepExecution,
Template,
Workflow,
} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
import {toPrismaJson} from '@plunk/types';
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
// Type aliases for workflow execution context
type StepConfig = Prisma.JsonValue;
type StepResult = Record<string, unknown>;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
// This is a delayed step - continue with execution
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
signale.info(
`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`,
);
return; // Already completed or cancelled
}
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: result ? (result as Prisma.InputJsonValue) : undefined,
output: result ? toPrismaJson(result) : undefined,
},
});
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: {
output: toPrismaJson({
eventName,
eventData: data ? (data as Prisma.InputJsonValue) : undefined,
eventData: data ? toPrismaJson(data) : undefined,
receivedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
}),
},
});
@@ -517,18 +519,25 @@ export class WorkflowExecutionService {
}
/**
* SEND_EMAIL step - Send an email to the contact
* SEND_EMAIL step - Send an email to the contact or a custom recipient
*/
private static async executeSendEmail(
step: WorkflowStepWithTemplate,
execution: WorkflowExecutionWithRelations,
stepExecution: WorkflowStepExecution,
_config: StepConfig,
config: StepConfig,
): Promise<StepResult> {
if (!step.template) {
throw new Error('No template configured for SEND_EMAIL step');
}
// Parse step config to get recipient configuration
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
const recipientConfig =
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
? (stepConfig.recipient as {type?: string; customEmail?: string})
: {type: 'CONTACT'};
// Get contact data for variable substitution
const contact = execution.contact;
const contactData =
@@ -543,6 +552,7 @@ export class WorkflowExecutionService {
: {};
const variables = {
id: contact.id,
email: contact.email,
...contactData,
...executionContext,
@@ -555,10 +565,20 @@ export class WorkflowExecutionService {
const renderedSubject = this.renderTemplate(step.template.subject, variables);
const renderedBody = this.renderTemplate(step.template.body, variables);
// Determine recipient email
let recipientEmail = contact.email;
let recipientContactId = contact.id;
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
recipientEmail = recipientConfig.customEmail;
// For custom recipients, we don't associate with a contact
recipientContactId = contact.id; // Keep original contact for tracking
}
// Send email via EmailService
const email = await EmailService.sendWorkflowEmail({
projectId: execution.workflow.projectId,
contactId: contact.id,
contactId: recipientContactId,
workflowExecutionId: execution.id,
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
templateId: step.template.id,
@@ -567,11 +587,15 @@ export class WorkflowExecutionService {
from: step.template.from,
fromName: step.template.fromName || undefined,
replyTo: step.template.replyTo || undefined,
// Pass custom recipient email if specified
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
});
return {
emailId: email.id,
sentAt: email.createdAt,
recipientType: recipientConfig.type,
recipientEmail,
};
}
@@ -866,7 +890,7 @@ export class WorkflowExecutionService {
await prisma.contact.update({
where: {id: contact.id},
data: {
data: newData ? (newData as Prisma.InputJsonValue) : undefined,
data: newData ? toPrismaJson(newData) : undefined,
},
});
+68 -47
View File
@@ -1,5 +1,7 @@
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
@@ -10,34 +12,16 @@ import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
export interface PaginatedWorkflows {
workflows: Workflow[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface WorkflowWithDetails extends Workflow {
steps: (WorkflowStep & {
template?: {id: string; name: string} | null;
outgoingTransitions: WorkflowTransition[];
incomingTransitions: WorkflowTransition[];
})[];
}
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
workflow: Workflow;
contact: {id: string; email: string};
currentStep?: WorkflowStep | null;
stepExecutions: WorkflowStepExecution[];
}
export class WorkflowService {
/**
* Get all workflows for a project with pagination
*/
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
public static async list(
projectId: string,
page = 1,
pageSize = 20,
search?: string,
): Promise<PaginatedResponse<Workflow>> {
const skip = (page - 1) * pageSize;
const where: Prisma.WorkflowWhereInput = {
@@ -71,7 +55,7 @@ export class WorkflowService {
]);
return {
workflows: workflows as Workflow[],
data: workflows as Workflow[],
total,
page,
pageSize,
@@ -210,25 +194,57 @@ export class WorkflowService {
}
}
const updateData: Prisma.WorkflowUpdateInput = {};
// Use transaction to update workflow and TRIGGER step atomically
const updated = await prisma.$transaction(async tx => {
const updateData: Prisma.WorkflowUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.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;
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},
const updatedWorkflow = await tx.workflow.update({
where: {id: workflowId},
data: updateData,
include: {
project: {
select: {name: true},
},
},
},
});
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
const eventName = newTriggerConfig?.eventName;
if (eventName) {
// Find the TRIGGER step
const triggerStep = await tx.workflowStep.findFirst({
where: {
workflowId: workflowId,
type: 'TRIGGER',
},
});
if (triggerStep) {
// Update TRIGGER step config and name to match
await tx.workflowStep.update({
where: {id: triggerStep.id},
data: {
name: `Trigger: ${eventName}`,
config: {eventName},
},
});
}
}
}
return updatedWorkflow;
});
// Invalidate workflow cache if enabled status changed or workflow is enabled
@@ -236,6 +252,11 @@ export class WorkflowService {
await EventService.invalidateWorkflowCache(projectId);
}
// Also invalidate cache if triggerConfig changed on an enabled workflow
if (data.triggerConfig !== undefined && updated.enabled) {
await EventService.invalidateWorkflowCache(projectId);
}
// Send notification if enabled status changed
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
if (data.enabled) {
@@ -321,8 +342,8 @@ export class WorkflowService {
workflowId,
type: data.type,
name: data.name,
position: data.position as Prisma.InputJsonValue,
config: data.config as Prisma.InputJsonValue,
position: toPrismaJson(data.position),
config: toPrismaJson(data.config),
templateId: data.templateId,
},
});
@@ -403,8 +424,8 @@ export class WorkflowService {
const updateData: Prisma.WorkflowStepUpdateInput = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
if (data.templateId !== undefined) {
if (data.templateId === null) {
updateData.template = {disconnect: true};
@@ -596,7 +617,7 @@ export class WorkflowService {
fromStepId: data.fromStepId,
condition: {
path: ['branch'],
equals: conditionObj.branch as Prisma.InputJsonValue,
equals: toPrismaJson(conditionObj.branch),
},
},
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService';
@@ -320,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
data: {billingLimitCampaigns: 10},
});
// Create a date in the previous month
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
const lastMonth = new Date();
lastMonth.setDate(1);
lastMonth.setMonth(lastMonth.getMonth() - 1);
await prisma.email.create({
@@ -1,5 +1,5 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -164,7 +164,7 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
expect(result.campaigns).toHaveLength(10);
expect(result.data).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
expect(result.page).toBe(1);
@@ -177,8 +177,8 @@ describe('CampaignService', () => {
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.campaigns).toHaveLength(2);
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
expect(result.data).toHaveLength(2);
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
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,
]);
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id},
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js';
@@ -110,9 +110,7 @@ describe('DomainService', () => {
it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i,
@@ -122,13 +120,13 @@ describe('DomainService', () => {
it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject();
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
HttpException,
);
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not registered/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should throw error when domain belongs to different project', async () => {
@@ -141,13 +139,11 @@ describe('DomainService', () => {
data: {verified: true},
});
await expect(
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('[email protected]', project2.id),
).rejects.toThrow(/belongs to a different project/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
/belongs to a different project/i,
);
});
it('should throw error when domain is not verified', async () => {
@@ -155,13 +151,11 @@ describe('DomainService', () => {
await DomainService.addDomain(project.id, 'unverified.com');
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not verified/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not verified/i,
);
});
it('should return domain when all checks pass', async () => {
@@ -358,9 +352,9 @@ describe('DomainService', () => {
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
});
@@ -391,9 +385,7 @@ describe('DomainService', () => {
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*template/i,
);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
});
it('should throw error when domain is used in active campaigns', async () => {
@@ -409,9 +401,7 @@ describe('DomainService', () => {
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*campaign/i,
);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
});
it('should allow removal when campaign is SENT (completed)', async () => {
@@ -433,9 +423,9 @@ describe('DomainService', () => {
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
it('should check usage in multiple templates', async () => {
@@ -492,17 +482,15 @@ describe('DomainService', () => {
expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail
await expect(
DomainService.verifyEmailDomain('[email protected]', project.id),
).rejects.toThrow(/not registered/i);
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
});
it('should handle email with multiple @ signs', async () => {
@@ -516,9 +504,7 @@ describe('DomainService', () => {
it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
});
});
@@ -537,11 +523,7 @@ describe('DomainService', () => {
]);
expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
});
it('should handle concurrent ownership checks', async () => {
@@ -1,11 +1,32 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
import {sendRawEmail} from '../SESService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock SES service
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
vi.mock('@aws-sdk/client-ses', () => {
const SESMock = vi.fn();
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
return {SES: SESMock};
});
// Mock constants to provide AWS credentials for SESService, preserving other exports
vi.mock('../../app/constants.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../app/constants.js')>();
return {
...actual,
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
AWS_SES_REGION: 'us-east-1',
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
SES_CONFIGURATION_SET: 'test-config-set',
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
TRACKING_TOGGLE_ENABLED: true,
};
});
// Mock SES service (default behavior for most tests)
vi.mock('../SESService', () => ({
sendRawEmail: vi.fn(),
}));
@@ -801,5 +822,204 @@ describe('EmailService', () => {
expect(result.success).toBe(true);
}
});
it('should accept inline attachment with contentId', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
from: '[email protected]',
subject: 'Inline Image',
body: '<img src="cid:logo" />',
attachments: [
{
filename: 'logo.png',
content: Buffer.from('image').toString('base64'),
contentType: 'image/png',
contentId: 'logo',
disposition: 'inline',
},
],
});
expect(result.success).toBe(true);
if (result.success) {
const attachment = result.data.attachments![0];
expect(attachment.contentId).toBe('logo');
expect(attachment.disposition).toBe('inline');
}
});
it('should reject contentId exceeding 255 chars', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'image.png',
content: Buffer.from('content').toString('base64'),
contentType: 'image/png',
contentId: 'a'.repeat(256),
disposition: 'inline',
},
],
});
expect(result.success).toBe(false);
});
it('should reject invalid disposition', () => {
const result = ActionSchemas.send.safeParse({
to: '[email protected]',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'image.png',
content: Buffer.from('content').toString('base64'),
contentType: 'image/png',
disposition: 'invalid-disposition',
},
],
});
expect(result.success).toBe(false);
});
});
});
// ========================================
// SES MIME BOUNDARY STRUCTURE
// ========================================
// These tests verify the raw MIME assembly logic inside sendRawEmail.
// They need the REAL sendRawEmail (not the mock above), so we mock
// at the AWS SDK level instead.
describe('SES MIME Boundary Structure', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
attachments: [
{
filename: 'test.txt',
content: 'SGVsbG8=',
contentType: 'text/plain',
disposition: 'attachment' as const,
},
],
};
await realSendRawEmail(params);
expect(ses.sendRawEmail).toHaveBeenCalled();
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Verify boundary hierarchy: Mixed -> Alternative
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
expect(rawMessage).toContain(`--${mixedBoundary}--`);
});
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
attachments: [
{
filename: 'image.png',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
contentType: 'image/png',
contentId: 'image1',
disposition: 'inline' as const,
},
],
};
await realSendRawEmail(params);
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Verify boundary hierarchy: Related -> Alternative
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
expect(rawMessage).toContain(`--${relatedBoundary}--`);
});
it('should correctly nest mixed > related > alternative boundaries', async () => {
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
const params = {
from: {name: 'Sender', email: '[email protected]'},
to: ['[email protected]'],
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
attachments: [
{
filename: 'test.txt',
content: 'SGVsbG8=',
contentType: 'text/plain',
disposition: 'attachment' as const,
},
{
filename: 'image.png',
content:
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
contentType: 'image/png',
contentId: 'image1',
disposition: 'inline' as const,
},
],
};
await realSendRawEmail(params);
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
// Root should be mixed
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
// Within mixed, we should find related
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
// Within related, we should find alternative
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
// Verify all closing boundaries exist
expect(rawMessage).toContain(`--${altBoundary}--`);
expect(rawMessage).toContain(`--${relatedBoundary}--`);
expect(rawMessage).toContain(`--${mixedBoundary}--`);
});
});
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match exact string values in standard fields (case-insensitive)', async () => {
@@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match boolean values', async () => {
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match numeric values as strings in JSON fields', async () => {
@@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should exclude boolean false values', async () => {
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
@@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
// notEquals only matches where field exists and has different value
expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingField.id);
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match substring in email field (case-insensitive)', async () => {
@@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
it('should not match when field does not exist', async () => {
@@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should match partial domain in email', async () => {
@@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(gmailUser.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(gmailUser.id);
});
});
@@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
@@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
// notContains only matches where field exists and doesn't contain substring
expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingSubstring.id);
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
});
@@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(high.id);
expect(ids).toContain(veryHigh.id);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
it('should exclude values equal to threshold', async () => {
@@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should work with negative numbers', async () => {
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should work with decimal values', async () => {
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(equal.id);
expect(ids).toContain(greater.id);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
});
@@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(low.id);
expect(ids).toContain(veryLow.id);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
it('should exclude values equal to threshold', async () => {
@@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
});
@@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(equal.id);
expect(ids).toContain(less.id);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
});
@@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(positive.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(positive.id);
});
it('should handle very large numbers', async () => {
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
});
@@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withField.id);
});
it('should exclude contacts where field is null', async () => {
@@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withValue.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withValue.id);
});
it('should match fields with empty string values', async () => {
@@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withEmptyString.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withEmptyString.id);
});
it('should match fields with zero values', async () => {
@@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withZero.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withZero.id);
});
it('should match fields with boolean false values', async () => {
@@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withFalse.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withFalse.id);
});
});
@@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withoutField.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withoutField.id);
});
it('should match contacts where field is null', async () => {
@@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withNull.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withNull.id);
});
it('should exclude fields with empty string values', async () => {
@@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should exclude fields with zero values', async () => {
@@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
});
});
@@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(recent.id);
});
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(veryRecent.id);
});
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(justNow.id);
});
});
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match contacts with JSON date field within specified hours', async () => {
@@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match contacts with JSON date field within specified minutes', async () => {
@@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should NOT match contacts with JSON date field outside the time range', async () => {
@@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should NOT match contacts where JSON date field does not exist', async () => {
@@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should handle null values in JSON date fields gracefully', async () => {
@@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should work correctly with combined filters (AND logic)', async () => {
@@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should require unit parameter for within operator', async () => {
@@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(newer.id);
expect(ids).not.toContain(older.id);
});
@@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(first.id);
expect(ids).toContain(second.id);
expect(ids).not.toContain(third.id);
@@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
@@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
@@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match.id);
});
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
@@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(ids).not.toContain(noMatch.id);
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should combine existence checks with value comparisons', async () => {
@@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should not match contacts who have not triggered the event', async () => {
@@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should match contacts with multiple occurrences of the same event', async () => {
@@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match contacts with event within time range (hours)', async () => {
@@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should match contacts with event within time range (minutes)', async () => {
@@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should handle events at exact boundary', async () => {
@@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
// Should not match because events at exact boundary are excluded
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should not match contacts with event outside time range', async () => {
@@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should match contact if any of their events is within range', async () => {
@@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should not match contacts who have triggered the event', async () => {
@@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
it('should match contacts with other events but not the target event', async () => {
@@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
@@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should combine multiple event filters', async () => {
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
});
});
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(subscribed.id);
});
it('should filter contacts by custom data fields', async () => {
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(proUser.id);
});
it('should filter contacts with multiple conditions', async () => {
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(target.id);
});
it('should support notEquals operator', async () => {
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(pro.id);
});
it('should support contains operator for strings', async () => {
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(match.id);
});
it('should support exists operator for custom fields', async () => {
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(withField.id);
});
it('should handle empty segments', async () => {
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
expect(result.total).toBe(0);
});
});
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2);
expect(result.data).toHaveLength(2);
});
it('should support pagination', async () => {
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
expect(page1.contacts).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
expect(page2.contacts).toHaveLength(10);
expect(page2.data).toHaveLength(10);
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
expect(page3.contacts).toHaveLength(5);
expect(page3.data).toHaveLength(5);
});
});
@@ -278,7 +278,7 @@ describe('SegmentService', () => {
// Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
// Update contact to pro plan
await prisma.contact.update({
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
// Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id);
expect(result.data).toHaveLength(1);
expect(result.data[0].id).toBe(contact.id);
});
it('should be removed from segment when criteria no longer met', async () => {
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
// Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.data).toHaveLength(1);
// Unsubscribe contact
await prisma.contact.update({
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
// Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.data).toHaveLength(0);
});
});
@@ -506,8 +506,8 @@ describe('SegmentService', () => {
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.data.map(c => c.id)).not.toContain(match.id);
});
it('should support case-insensitive equals/contains for email strings', async () => {
@@ -526,7 +526,7 @@ describe('SegmentService', () => {
});
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
const equalsIds = equalsResult.contacts.map(c => c.id);
const equalsIds = equalsResult.data.map(c => c.id);
expect(equalsIds).toContain(lower.id);
expect(equalsIds).toContain(upper.id);
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
});
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
const containsIds = containsResult.contacts.map(c => c.id);
const containsIds = containsResult.data.map(c => c.id);
expect(containsIds).toContain(lower.id);
expect(containsIds).toContain(upper.id);
});
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id);
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
});
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
const notContainsIds = notContainsResult.contacts.map(c => c.id);
const notContainsIds = notContainsResult.data.map(c => c.id);
expect(notContainsIds).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id);
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
});
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
const notEqualsIds = notEqualsResult.data.map(c => c.id);
expect(notEqualsIds).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id);
});
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
});
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
const existsIds = new Set(existsResult.contacts.map(c => c.id));
const existsIds = new Set(existsResult.data.map(c => c.id));
expect(existsIds.has(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false);
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
});
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
expect(notExistsIds.has(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
});
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
});
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
const gtIds = greaterThanResult.contacts.map(c => c.id);
const gtIds = greaterThanResult.data.map(c => c.id);
expect(gtIds).toContain(mid.id);
expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id);
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
});
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(low.id);
expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id);
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
});
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
const gtIds = gtResult.contacts.map(c => c.id);
const gtIds = gtResult.data.map(c => c.id);
expect(gtIds).toContain(newer.id);
expect(gtIds).not.toContain(older.id);
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
});
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
const lteIds = lteResult.data.map(c => c.id);
expect(lteIds).toContain(older.id);
expect(lteIds).toContain(newer.id);
});
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
const ids = result.data.map(c => c.id);
expect(ids).toContain(recent.id);
});
});
@@ -1,4 +1,4 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
}
const page1 = await TemplateService.list(projectId, 1, 10);
expect(page1.templates).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10);
expect(page2.data).toHaveLength(10);
expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5);
expect(page3.data).toHaveLength(5);
expect(page3.page).toBe(3);
});
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should filter templates by search query (description)', async () => {
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2);
expect(result.templates.map(t => t.description)).toEqual(
expect(result.data.map(t => t.description)).toEqual(
expect.arrayContaining([expect.stringContaining('new')]),
);
});
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2);
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1);
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
});
it('should combine search and type filters', async () => {
@@ -223,7 +223,7 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1);
expect(result.templates[0].name).toBe('Welcome Email');
expect(result.data[0].name).toBe('Welcome Email');
});
it('should return templates ordered by creation date (newest first)', async () => {
@@ -236,9 +236,9 @@ describe('TemplateService', () => {
const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest
expect(result.data[0].id).toBe(template3.id); // Newest
expect(result.data[1].id).toBe(template2.id);
expect(result.data[2].id).toBe(template1.id); // Oldest
});
it('should only return templates for the specified project', async () => {
@@ -1,5 +1,6 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium Status',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.isPremium',
operator: 'equals',
value: true,
},
}),
},
});
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Premium Path',
position: {x: 200, y: -50},
config: {reason: 'Premium customer'},
config: toPrismaJson({reason: 'Premium customer'}),
},
});
@@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Standard Path',
position: {x: 200, y: 50},
config: {reason: 'Standard customer'},
config: toPrismaJson({reason: 'Standard customer'}),
},
});
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
priority: 1,
},
});
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
priority: 2,
},
});
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.isPremium',
operator: 'equals',
value: true,
},
}),
},
});
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Premium',
position: {x: 200, y: -50},
config: {},
config: toPrismaJson({}),
},
});
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Standard',
position: {x: 200, y: 50},
config: {},
config: toPrismaJson({}),
},
});
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Country',
position: {x: 100, y: 0},
config: {field: 'data.country', operator: 'equals', value: 'US'},
config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}),
},
});
@@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Premium (US)',
position: {x: 200, y: -50},
config: {field: 'data.isPremium', operator: 'equals', value: true},
config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}),
},
});
@@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'US Premium',
position: {x: 300, y: -75},
config: {},
config: toPrismaJson({}),
},
});
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'US Standard',
position: {x: 300, y: -25},
config: {},
config: toPrismaJson({}),
},
});
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Non-US',
position: {x: 200, y: 50},
config: {},
config: toPrismaJson({}),
},
});
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition1.id,
toStepId: condition2.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition1.id,
toStepId: nonUsExit.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition2.id,
toStepId: usPremiumExit.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition2.id,
toStepId: usStandardExit.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Purchase',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
eventName: 'purchase.completed',
timeout: 3600, // 1 hour
},
}),
},
});
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Event',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
eventName: 'user.verified',
timeout: 3600,
},
}),
},
});
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Wait 1 day',
position: {x: 100, y: 0},
config: {amount: 1, unit: 'days'},
config: toPrismaJson({amount: 1, unit: 'days'}),
},
});
@@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Status',
position: {x: 200, y: 0},
config: {field: 'contact.subscribed', operator: 'equals', value: true},
config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}),
},
});
@@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 300, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: exit.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'A/B Split',
position: {x: 100, y: 0},
config: {field: 'data.segment', operator: 'equals', value: 'A'},
config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}),
},
});
@@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Path A Delay',
position: {x: 200, y: -50},
config: {amount: 1, unit: 'hours'},
config: toPrismaJson({amount: 1, unit: 'hours'}),
},
});
@@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.DELAY,
name: 'Path B Delay',
position: {x: 200, y: 50},
config: {amount: 2, unit: 'hours'},
config: toPrismaJson({amount: 2, unit: 'hours'}),
},
});
@@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Merge Point',
position: {x: 300, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: pathA.id,
condition: {branch: 'yes'},
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: pathB.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Bad Condition',
position: {x: 100, y: 0},
config: {}, // Invalid - missing required fields
config: toPrismaJson({}), // Invalid - missing required fields
},
});
@@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check Missing Field',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'data.nonExistentField',
operator: 'equals',
value: 'something',
},
}),
},
});
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Exit',
position: {x: 200, y: 0},
config: {},
config: toPrismaJson({}),
},
});
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: condition.id,
toStepId: noStep.id,
condition: {branch: 'no'},
condition: toPrismaJson({branch: 'no'}),
},
});
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Early Exit',
position: {x: 100, y: 0},
config: {reason: 'User already converted'},
config: toPrismaJson({reason: 'User already converted'}),
},
});
@@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
context: toPrismaJson({}),
},
});
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.TRIGGER,
name: 'Start',
position: {x: 0, y: 0},
config: {},
config: toPrismaJson({}),
});
const exitStep = await factories.createWorkflowStep({
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'End',
position: {x: 100, y: 0},
config: {},
config: toPrismaJson({}),
});
await prisma.workflowTransition.create({
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: contextData as Prisma.InputJsonValue,
context: toPrismaJson(contextData),
},
});
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check if first open',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.isFirstOpen',
operator: 'equals',
value: true, // Use boolean, not string
} as Prisma.InputJsonValue,
}),
},
});
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'First Open',
position: {x: 200, y: 0},
config: {reason: 'first_open'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'first_open'}),
},
});
@@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Not First Open',
position: {x: 200, y: 100},
config: {reason: 'not_first_open'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'not_first_open'}),
},
});
@@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue},
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})},
});
await prisma.workflowTransition.create({
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue},
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})},
});
// Create execution with event data
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome Email',
from: '[email protected]',
isFirstOpen: true,
openedAt: new Date().toISOString(),
} as Prisma.InputJsonValue,
}),
},
});
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check subject',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.subject',
operator: 'contains',
value: 'Welcome',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {reason: 'matched'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'matched'}),
},
});
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: exitStep.id,
condition: {branch: 'yes'} as Prisma.InputJsonValue,
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome to Plunk!',
from: '[email protected]',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.CONDITION,
name: 'Check opens count',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
field: 'event.opens',
operator: 'greaterThan',
value: '3',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {reason: 'engaged'} as Prisma.InputJsonValue,
config: toPrismaJson({reason: 'engaged'}),
},
});
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
data: {
fromStepId: conditionStep.id,
toStepId: exitStep.id,
condition: {branch: 'yes'} as Prisma.InputJsonValue,
condition: toPrismaJson({branch: 'yes'}),
},
});
@@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Newsletter',
opens: 5,
} as Prisma.InputJsonValue,
}),
},
});
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
type: WorkflowStepType.WEBHOOK,
name: 'Send Webhook',
position: {x: 100, y: 0},
config: {
config: toPrismaJson({
url: 'https://webhook.example.com/test',
method: 'POST',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep!.id,
context: {
context: toPrismaJson({
subject: 'Welcome Email',
from: '[email protected]',
messageId: 'msg-123',
isFirstOpen: true,
openedAt: '2024-01-15T10:00:00Z',
} as Prisma.InputJsonValue,
}),
},
});
@@ -1,9 +1,9 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {beforeEach, describe, expect, it} from 'vitest';
import {
WorkflowStepType,
StepExecutionStatus,
WorkflowExecutionStatus,
TemplateType,
WorkflowExecutionStatus,
WorkflowStepType,
WorkflowTriggerType,
} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
@@ -205,7 +205,7 @@ describe('WorkflowService', () => {
const page1 = await WorkflowService.list(projectId, 1, 10);
expect(page1.workflows).toHaveLength(10);
expect(page1.data).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
});
@@ -218,7 +218,7 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should include step and execution counts', async () => {
@@ -234,8 +234,8 @@ describe('WorkflowService', () => {
const result = await WorkflowService.list(projectId);
const found = result.workflows.find(w => w.id === workflow.id) as
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}})
const found = result.data.find(w => w.id === workflow.id) as
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
| undefined;
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
expect(found?._count.executions).toBe(1);
+31
View File
@@ -26,6 +26,12 @@ export const Keys = {
project(projectId: string): string {
return `domain:project:${projectId}`;
},
verifiedEmail(domainId: string): string {
return `domain:verified_email:${domainId}`;
},
unverifiedEmail(domainId: string, year: number, month: string): string {
return `domain:unverified_email:${domainId}:${year}-${month}`;
},
},
Billing: {
usage(projectId: string, sourceType: string, year: number, month: string): string {
@@ -64,4 +70,29 @@ export const Keys = {
return `workflows:enabled:${projectId}`;
},
},
Membership: {
access(userId: string, projectId: string): string {
return `membership:access:${userId}:${projectId}`;
},
admin(userId: string, projectId: string): string {
return `membership:admin:${userId}:${projectId}`;
},
full(userId: string, projectId: string): string {
return `membership:full:${userId}:${projectId}`;
},
owner(projectId: string): string {
return `membership:owner:${projectId}`;
},
},
Project: {
id(id: string): string {
return `project:id:${id}`;
},
secret(key: string): string {
return `project:secret:${key}`;
},
public(key: string): string {
return `project:public:${key}`;
},
},
} as const;
+1 -1
View File
@@ -1,5 +1,5 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
generateRobotsTxt: true,
};
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.10",
"next": "^16.1.5",
"next-seo": "^6.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -128,6 +128,11 @@ export default function Footer() {
Terms
</Link>
</li>
<li>
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
DPA
</Link>
</li>
</ul>
</div>
</div>
@@ -25,7 +25,7 @@ export function GuideLayout({
readTime,
children,
canonical,
ogImage = 'https://www.useplunk.com/assets/card.png',
ogImage = 'https://next.useplunk.com/assets/card.png',
}: GuideLayoutProps) {
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
const [activeId, setActiveId] = useState<string>('');
@@ -74,8 +74,8 @@ export function GuideLayout({
// Generate breadcrumb items
const breadcrumbItems = [
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
{position: 3, name: title, item: canonical || ''},
];
@@ -1,5 +1,17 @@
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
import {
AlertCircle,
AlertTriangle,
CheckCircle,
Forward,
Info,
Mail,
Server,
Shield,
Trash2,
User,
XCircle,
} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
interface EmailVerificationResultProps {
result: VerificationResult;
@@ -41,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
<Server className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Domain Exists</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
<p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
</div>
</div>
{result.domainExists ? (
@@ -67,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)}
</div>
{/* Website/A Records */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Has Website</p>
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
</div>
</div>
{result.hasWebsite ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Info className="h-5 w-5 text-neutral-400" />
)}
</div>
{/* Disposable Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
@@ -83,6 +111,38 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)}
</div>
{/* Personal Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<User className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Personal Email</p>
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
</div>
</div>
{result.isPersonalEmail ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<span className="text-sm text-neutral-500">No</span>
)}
</div>
{/* Alias/Forwarding Email */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
<Forward className="h-5 w-5 text-neutral-600" />
<div>
<p className="font-medium text-neutral-900">Forwarding Service</p>
<p className="text-sm text-neutral-600">Email alias/forwarding detected</p>
</div>
</div>
{result.isAlias ? (
<Info className="h-5 w-5 text-blue-600" />
) : (
<CheckCircle className="h-5 w-5 text-green-600" />
)}
</div>
{/* Typo Detection */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
const fragment = convertToEmailHtml(html);
return wrapEmailHtml(fragment);
}
+1 -11
View File
@@ -1,14 +1,4 @@
export interface EmailVerificationResult {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
import type {EmailVerificationResult} from '@plunk/types';
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
const response = await fetch('/api/verify-email', {
+6 -1
View File
@@ -1,4 +1,4 @@
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
/**
* Educational content for the email verification tool
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
icon: Mail,
},
{
title: 'Personal Email Detection',
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
icon: User,
},
{
title: 'Plus Addressing',
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
+1 -1
View File
@@ -63,7 +63,7 @@ export default function WithProviders(props: AppProps) {
title: 'Plunk | The Open-Source Email Platform',
description:
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
}}
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
/>
+3 -14
View File
@@ -1,26 +1,15 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import type {EmailVerificationResult} from '@plunk/types';
import {UtilitySchemas} from '@plunk/shared';
import {API_URI} from '../../lib/constants';
interface VerifyEmailResponse {
email: string;
valid: boolean;
isDisposable: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
interface ErrorResponse {
error: string;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
) {
// Only allow POST requests
if (req.method !== 'POST') {
@@ -49,7 +38,7 @@ export default async function handler(
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${secretKey}`,
'Authorization': `Bearer ${secretKey}`,
},
body: JSON.stringify({email}),
});
+695
View File
@@ -0,0 +1,695 @@
import {NextSeo} from 'next-seo';
import React from 'react';
import {Navbar} from '../components';
export default function DataProcessingAgreement() {
return (
<>
<NextSeo
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
openGraph={{
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
description:
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
}}
additionalMetaTags={[
{
property: 'title',
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
},
]}
/>
<div className="min-h-screen bg-white">
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
<Navbar />
<div className="mx-auto max-w-4xl py-16">
{/* Hero */}
<div className="mb-12">
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
<p className="mt-4 text-base leading-relaxed text-neutral-700">
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
accordance with GDPR requirements.
</p>
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
<p className="text-sm leading-relaxed text-blue-900">
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
</p>
</div>
</div>
{/* Table of Contents */}
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
<ol className="space-y-2 text-sm text-neutral-700">
<li>
<a href="#definitions" className="text-blue-600 hover:underline">
1. Definitions
</a>
</li>
<li>
<a href="#scope" className="text-blue-600 hover:underline">
2. Scope & Applicability
</a>
</li>
<li>
<a href="#processing-details" className="text-blue-600 hover:underline">
3. Processing Details
</a>
</li>
<li>
<a href="#obligations" className="text-blue-600 hover:underline">
4. Data Processor Obligations
</a>
</li>
<li>
<a href="#security" className="text-blue-600 hover:underline">
5. Security Measures
</a>
</li>
<li>
<a href="#sub-processors" className="text-blue-600 hover:underline">
6. Sub-Processors
</a>
</li>
<li>
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
7. Data Subject Rights
</a>
</li>
<li>
<a href="#data-breach" className="text-blue-600 hover:underline">
8. Data Breach Notification
</a>
</li>
<li>
<a href="#international-transfers" className="text-blue-600 hover:underline">
9. International Data Transfers
</a>
</li>
<li>
<a href="#audits" className="text-blue-600 hover:underline">
10. Audits & Compliance
</a>
</li>
<li>
<a href="#deletion" className="text-blue-600 hover:underline">
11. Data Deletion & Return
</a>
</li>
<li>
<a href="#liability" className="text-blue-600 hover:underline">
12. Liability & Indemnification
</a>
</li>
<li>
<a href="#term" className="text-blue-600 hover:underline">
13. Term & Termination
</a>
</li>
</ol>
</nav>
{/* Content Sections */}
<div className="space-y-12">
{/* Section 1 */}
<section id="definitions" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
relating to an identified or identifiable natural person processed via Plunk
</li>
<li>
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
determines the purposes and means of processing Personal Data
</li>
<li>
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
processes Personal Data on behalf of the Data Controller
</li>
<li>
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
engaged by Plunk to process Personal Data
</li>
<li>
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
whose Personal Data is processed (your contacts/subscribers)
</li>
<li>
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
(General Data Protection Regulation)
</li>
</ul>
</section>
{/* Section 2 */}
<section id="scope" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
self-hosting.
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
processing:
</p>
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
<li>This DPA takes precedence</li>
<li>Then the Terms of Service</li>
<li>Then the Privacy Policy</li>
</ol>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
</p>
</section>
{/* Section 3 */}
<section id="processing-details" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
As required by GDPR Article 28(3), the following details describe the processing activities:
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Processing of Personal Data necessary to provide email automation, transactional email, marketing
campaigns, and workflow automation services.
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
(which are automatically deleted), and until you request account deletion.
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Storing and managing contact databases</li>
<li>Sending transactional, marketing, and workflow-triggered emails</li>
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
<li>Managing email templates and automation workflows</li>
<li>Processing email events and webhooks</li>
<li>Providing analytics and reporting</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Email addresses (required)</li>
<li>Names and custom contact fields (optional, Customer-defined)</li>
<li>Email content (subject lines, message bodies, attachments)</li>
<li>Subscription status and preferences</li>
<li>Email activity data (opens, clicks, bounces, complaints)</li>
<li>Timestamps and metadata</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Customer's email subscribers and contacts</li>
<li>Recipients of transactional emails</li>
<li>Marketing campaign recipients</li>
<li>Workflow automation recipients</li>
</ul>
</section>
{/* Section 4 */}
<section id="obligations" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
Plunk commits to the following obligations as Data Processor:
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
</li>
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
<li>
Not disclose Personal Data to third parties except as required by law or with Customer consent
</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
<li>
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
assessment obligations
</li>
<li>Provide information necessary to demonstrate compliance with Article 28</li>
</ul>
</section>
{/* Section 5 */}
<section id="security" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
ensure a level of security appropriate to the risk:
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Industry-standard password hashing (never stored in plaintext)</li>
<li>HTTPS-only API access (TLS 1.2+)</li>
<li>Database connections encrypted via TLS/SSL</li>
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
<li>Authentication tokens with limited expiration periods</li>
<li>Rate limiting to prevent brute force attacks</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Access controls limiting personnel access to Personal Data</li>
<li>Automated bounce and complaint rate monitoring</li>
<li>Regular security updates and patching</li>
<li>Data segregation by project (multi-tenancy with isolation)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
<li>Database and file storage remain within EU/EEA</li>
<li>Email delivery may transit non-EU regions (see Section 9)</li>
</ul>
</section>
{/* Section 6 */}
<section id="sub-processors" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
</p>
<div className="mb-6 overflow-x-auto">
<table className="min-w-full border border-neutral-300">
<thead className="bg-neutral-50">
<tr>
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
Sub-Processor
</th>
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
Service
</th>
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
Location
</th>
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
Purpose
</th>
</tr>
</thead>
<tbody className="bg-white">
<tr>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Amazon Web Services (AWS SES)
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Global (data in transit only)
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Sending emails to recipients
</td>
</tr>
<tr>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Payment Processing
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
USA (PCI-DSS compliant)
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Billing for paid accounts
</td>
</tr>
<tr>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Hetzner Online GmbH
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Infrastructure Hosting
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
EU/EEA (Germany)
</td>
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
Database and application hosting
</td>
</tr>
</tbody>
</table>
</div>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
<li>Notice will be sent to the email address associated with your account</li>
<li>
If you object on reasonable grounds related to data protection, you may terminate your account
within 30 days
</li>
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
</ul>
</section>
{/* Section 7 */}
<section id="data-subject-rights" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Customer can fulfill most Data Subject requests independently via API:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
via GET /contacts/:id
</li>
<li>
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
data via PATCH /contacts/:id
</li>
<li>
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
DELETE /contacts/:id
</li>
<li>
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
subscription status to "unsubscribed"
</li>
<li>
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
data in JSON format
</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
If Customer cannot fulfill a request via API, contact legal@useplunk.com:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Provide Data Subject's email address and nature of request</li>
<li>Plunk will respond within 10 business days with requested information or assistance</li>
<li>
Customer remains responsible for verifying Data Subject identity before disclosing information
</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
<li>Deleting a contact will cascade delete associated email activity</li>
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
</ul>
</section>
{/* Section 8 */}
<section id="data-breach" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
In the event of a Personal Data breach affecting Customer data, Plunk will:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
Art. 33)
</li>
<li>Send notification to the primary email address associated with Customer's account</li>
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Notifications will include (to the extent known):
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Nature of the breach (what happened)</li>
<li>Categories and approximate number of Data Subjects affected</li>
<li>Categories and approximate number of Personal Data records affected</li>
<li>Likely consequences of the breach</li>
<li>Measures taken or proposed to address the breach and mitigate harm</li>
<li>Contact point for more information (legal@useplunk.com)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
</ul>
</section>
{/* Section 9 */}
<section id="international-transfers" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
When emails are sent to recipients, Personal Data may transit through non-EU regions:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>AWS SES processes emails globally for delivery purposes</li>
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
<li>Stripe is PCI-DSS Level 1 certified</li>
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
transfers per GDPR Article 46.
</p>
</section>
{/* Section 10 */}
<section id="audits" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
Article 28 obligations, including:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>This DPA (publicly available)</li>
<li>Privacy Policy describing processing activities</li>
<li>Sub-processor list (Section 6 above)</li>
<li>Security measures documentation (available upon reasonable request)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Customer may audit Plunk's compliance with this DPA, subject to the following:
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Audits limited to once per year unless required by supervisory authority</li>
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
<li>Audits conducted during business hours with minimal disruption to operations</li>
<li>Customer bears all costs of audit</li>
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
compliance documentation (SOC 2, ISO 27001, etc.) where available.
</p>
</section>
{/* Section 11 */}
<section id="deletion" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
When Customer deletes their Plunk account (via dashboard or by request):
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>All Personal Data is immediately deleted from production systems</li>
<li>No grace period or recovery window (deletion is permanent)</li>
<li>
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
</li>
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
<li>Backup data is not accessible or restorable after account deletion</li>
<li>Backups are automatically overwritten after retention period</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
<li>Legal requests: Data subject to legal holds retained as required by law</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
before deleting account. Once deleted, data cannot be recovered.
</p>
</section>
{/* Section 12 */}
<section id="liability" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
</li>
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
<li>Verifying Data Subject identity before fulfilling rights requests</li>
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
<li>
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
82)
</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
laws
</li>
<li>
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
28
</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
fault. Each party is liable only for the damage caused by its own GDPR violation.
</p>
</section>
{/* Section 13 */}
<section id="term" className="mb-12">
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
the duration of the Terms of Service.
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Customer deletes their Plunk account</li>
<li>Terms of Service are terminated</li>
<li>All Personal Data has been deleted per Section 11</li>
</ul>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
<p className="mb-4 leading-relaxed text-neutral-700">
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
necessary to fulfill their purpose.
</p>
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
<li>Material changes will be notified via email 30 days in advance</li>
<li>Continued use of Plunk after changes constitutes acceptance</li>
<li>Check "Last Updated" date at top of page for version tracking</li>
</ul>
</section>
{/* Contact Section */}
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
<p className="mb-4 leading-relaxed text-neutral-700">
For questions about this DPA, data processing activities, or to exercise audit rights:
</p>
<p className="mb-2 leading-relaxed text-neutral-700">
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
</p>
<p className="mb-4 leading-relaxed text-neutral-700">
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
</p>
<p className="text-sm text-neutral-600">
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
legal@useplunk.com with Data Subject's email address and nature of request.
</p>
</section>
</div>
</div>
</div>
</div>
</>
);
}
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-api-guide"
canonical="https://next.useplunk.com/guides/email-api-guide"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -132,7 +132,7 @@ export default function EmailAPIGuide() {
language="javascript"
title="Node.js Example"
code={`// Using fetch (Node.js 18+ or with node-fetch)
const response = await fetch('https://api.useplunk.com/v1/send', {
const response = await fetch('https://next-api.useplunk.com/v1/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -167,7 +167,7 @@ if (response.ok) {
code={`import requests
response = requests.post(
'https://api.useplunk.com/v1/send',
'https://next-api.useplunk.com/v1/send',
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_API_KEY'
@@ -195,7 +195,7 @@ else:
language="php"
title="PHP Example"
code={`<?php
$ch = curl_init('https://api.useplunk.com/v1/send');
$ch = curl_init('https://next-api.useplunk.com/v1/send');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
@@ -235,7 +235,7 @@ if ($httpCode === 200) {
code={`require 'net/http'
require 'json'
uri = URI('https://api.useplunk.com/v1/send')
uri = URI('https://next-api.useplunk.com/v1/send')
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
@@ -295,7 +295,7 @@ func main() {
jsonData, _ := json.Marshal(email)
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
req, _ := http.NewRequest("POST", "https://next-api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
lastUpdated="2025-12-20"
readTime="9 min"
canonical="https://www.useplunk.com/guides/email-bounce-rate"
canonical="https://next.useplunk.com/guides/email-bounce-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
lastUpdated="2025-12-20"
readTime="10 min"
canonical="https://www.useplunk.com/guides/email-click-through-rate"
canonical="https://next.useplunk.com/guides/email-click-through-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
lastUpdated="2025-12-20"
readTime="12 min"
canonical="https://www.useplunk.com/guides/email-deliverability"
canonical="https://next.useplunk.com/guides/email-deliverability"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -306,9 +306,7 @@ export default function EmailDeliverability() {
{/* Monitoring */}
<section id="monitoring" className="mb-12">
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
<p className="text-neutral-700 leading-relaxed mb-6">
Track these metrics to stay on top of deliverability:
</p>
<p className="text-neutral-700 leading-relaxed mb-6">Track these metrics to stay on top of deliverability:</p>
<div className="grid gap-6 md:grid-cols-2">
<div className="rounded-xl border border-neutral-200 bg-white p-6">
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
lastUpdated="2025-12-20"
readTime="15 min"
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
lastUpdated="2025-12-20"
readTime="10 min"
canonical="https://www.useplunk.com/guides/email-open-rate"
canonical="https://next.useplunk.com/guides/email-open-rate"
>
{/* Introduction */}
<section id="introduction" className="mb-12">
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
lastUpdated="2025-12-20"
readTime="11 min"
canonical="https://www.useplunk.com/guides/email-sender-reputation"
canonical="https://next.useplunk.com/guides/email-sender-reputation"
>
{/* Introduction */}
<section id="introduction" className="mb-12">

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