Compare commits

...
407 Commits
Author SHA1 Message Date
Dries Augustyns cbde3cce3f fix: Sync display name in TemplateSearchPicker when initialName changes 2026-04-20 19:38:20 +02:00
Doug Beatty df18979e5e extract TemplateSearchPicker to fix workflow template pagination bug 2026-04-12 10:14:34 -05:00
Doug Beatty a981a8bba4 Improve template template loading and debounced search 2026-04-12 00:33:37 -05:00
Dries Augustyns 7834b9e7ef fix: Update language validation regex to support locale variants 2026-04-05 11:42:25 +02:00
Dries Augustyns 9e2400c6de fix: Enhance email processing to support campaign types and improve unsubscribe logic 2026-04-02 14:09:01 +02:00
Dries Augustyns fb5aa8796a feat: Add headless template type 2026-04-02 12:43:27 +02:00
Dries Augustyns 284838279d test: Mock DNS lookup in integration tests for WorkflowExecutionService 2026-04-02 07:56:54 +02:00
Dries Augustyns 2c5a71518d fix: Implement SSRF protection in webhook handling with safeFetch method 2026-04-02 07:48:43 +02:00
Dries AugustynsandGitHub a8014cf7cf Merge pull request #320 from hanamizuki/fix/wait-for-event-combobox 2026-04-01 18:56:00 +02:00
Dries Augustyns 3214f6c42d fix: Hint custom event names in combobox when no matches are found 2026-04-01 18:55:32 +02:00
Dries Augustyns d24259e8d2 feat: Add type to campaign 2026-04-01 18:49:51 +02:00
Dries Augustyns 3343e891bd feat: Disable projects on failed payment 2026-04-01 18:07:08 +02:00
Dries AugustynsandGitHub 6f99d4190c Merge pull request #313 from useplunk/release-please--branches--next--components--plunk 2026-03-31 19:24:31 +02:00
github-actions[bot]andGitHub 5f990573c1 chore(next): release 0.8.0 2026-03-31 17:23:45 +00:00
Dries Augustyns b50b5af763 fix: Enhance email step configuration validation and recipient handling 2026-03-31 19:22:46 +02:00
Dries AugustynsandGitHub 35c9e4c34e Merge pull request #335 from schue30/fix/add-sns-webhook-eusc-partition-support 2026-03-29 17:38:38 +02:00
Mathias Schüpany 7b6540a748 fix: Adapt SNS Webhook validation regex pattern to also support AWS eusc partition 2026-03-29 11:32:04 +02:00
Dries AugustynsandGitHub 4b290fd0b7 Merge pull request #333 from useplunk/dependabot/npm_and_yarn/picomatch-2.3.2 2026-03-27 08:18:44 +01:00
Dries AugustynsandGitHub f9d7cf2453 Merge pull request #328 from useplunk/dependabot/npm_and_yarn/flatted-3.4.2 2026-03-27 08:18:27 +01:00
Dries AugustynsandGitHub ac8aeebfbe Merge pull request #323 from useplunk/dependabot/npm_and_yarn/next-16.1.7 2026-03-27 08:18:09 +01:00
Dries AugustynsandGitHub 1675b07694 Merge pull request #314 from useplunk/dependabot/npm_and_yarn/tar-7.5.11 2026-03-27 08:17:50 +01:00
dependabot[bot]andGitHub d4f41892cd chore(deps): bump picomatch from 2.3.1 to 2.3.2
Bumps [picomatch](https://github.com/micromatch/picomatch) from 2.3.1 to 2.3.2.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 07:43:09 +00:00
Dries AugustynsandGitHub 5046c21045 Merge pull request #321 from hanamizuki/feat/i18n-zh-tw 2026-03-24 10:02:08 +01:00
Dries AugustynsandGitHub 9c50e1a7b4 Merge pull request #329 from edvin3443/fix/logo-alt-attribute 2026-03-24 09:47:39 +01:00
Hana ChangandClaude Opus 4.6 33df5632a3 feat(i18n): add Chinese translations (zh-TW, zh-HK, zh-CN)
Add three Chinese locale variants with region-specific flags:
- zh-TW: Traditional Chinese (Taiwan) 🇹🇼
- zh-HK: Traditional Chinese (Hong Kong) 🇭🇰
- zh-CN: Simplified Chinese (China) 🇨🇳

Each variant uses region-appropriate terminology (e.g. 郵件/電郵/邮件
for "email"), consistent with the existing pattern of tying languages
to specific regions (e.g. Spanish (Spain), Portuguese with 🇧🇷).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:45:18 +08:00
Hana ChangandClaude Opus 4.6 3e3117e4a7 fix: replace Select/datalist with styled Combobox for event name inputs
Replace the conditional Select/Input pattern with a unified Combobox
(Input + Command dropdown) that always allows free-text input while
offering autocomplete suggestions from previously tracked events.

Applied to both workflow creation dialog and edit page (trigger event,
add WAIT_FOR_EVENT step, edit WAIT_FOR_EVENT step).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:44:45 +08:00
Edvin 3b39d847cd fix: add alt text to email badge image 2026-03-23 09:08:24 +00:00
dependabot[bot]andGitHub dff25c62d9 chore(deps): bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 11:14:02 +00:00
Dries Augustyns 200dfb1dbf Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-03-19 15:18:51 +01:00
Dries Augustyns a99cde8839 fix: Enhance email step configuration validation and recipient handling 2026-03-19 15:18:40 +01:00
Dries AugustynsandGitHub 0e8b6f813d Merge pull request #325 from Sigmabrogz/feat/i18n-it 2026-03-18 21:10:39 +01:00
Sigmabrogz 7b6872c4b7 feat(i18n): add Italian translation 2026-03-18 20:05:39 +00:00
dependabot[bot]andGitHub c17f7065d9 chore(deps): bump next from 16.1.6 to 16.1.7
Bumps [next](https://github.com/vercel/next.js) from 16.1.6 to 16.1.7.
- [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.1.6...v16.1.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-17 15:54:18 +00:00
Dries AugustynsandGitHub f44d987bc9 Merge pull request #310 from mdwt/feature/multi-branch-conditions 2026-03-14 16:21:25 +01:00
Dries Augustyns dc2ce02d1a docs: Fix broken links on api overview page 2026-03-14 08:15:46 +01:00
Dries Augustyns b9d692cf5b fix: normalize field references and handle undefined values in JSON 2026-03-13 20:26:28 +01:00
Dries Augustyns c19cf531b8 Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-03-12 17:25:30 +01:00
Dries Augustyns f0ef34646e docs: Improve self-hosting env variable documentation 2026-03-12 17:24:37 +01:00
dependabot[bot]andGitHub 6a340e9554 chore(deps): bump tar from 7.5.10 to 7.5.11
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.10 to 7.5.11.
- [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.10...v7.5.11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-11 21:35:37 +00:00
Dries AugustynsandGitHub 38565bbce2 Merge pull request #312 from mikkelsvartveit/fix/wiki-links 2026-03-11 16:48:52 +01:00
Dries Augustyns c32b54f588 fix: Prevent switching if nodes are attached to multi-branch 2026-03-11 15:30:00 +01:00
Mikkel Svartveit 44987d2b5e fix: update old references to next.useplunk.com 2026-03-11 15:11:13 +01:00
Dries Augustyns 2c3e8970cb fix: Connect branches smoothly to original node 2026-03-11 14:52:30 +01:00
Dries AugustynsandGitHub 395af0080c Merge pull request #311 from mikkelsvartveit/fix/email-editor-preview 2026-03-11 14:45:44 +01:00
Mikkel Svartveit e3ea5fb6dc fix: broken links to next-wiki.useplunk.com 2026-03-11 14:20:59 +01:00
Mikkel Svartveit 4451e921ca fix: visual editor preview 2026-03-11 14:04:47 +01:00
mdwtandClaude Opus 4.6 92949b7596 feat: Add multi-branch workflow conditions (switch/case)
Add a `mode: 'multi'` variant to CONDITION steps that allows matching a
field against multiple values, each routing to its own named branch.
Branches are evaluated in order; first match wins. A `default` branch
catches unmatched contacts.

- Schema: z.union for legacy binary + multi-branch condition configs
- Backend: executeCondition handles multi-branch evaluation with stable
  branch IDs, falls back to default when no branch matches
- Builder: Dynamic branch rendering with per-branch colors/labels,
  dagre layout spreads branches horizontally
- Config UI: Mode toggle (Simple If/Else vs Multi-branch Switch),
  dynamic branch list with add/remove
- Tests: 8 new multi-branch tests covering matching, ordering, default
  fallback, mixed operators, and transition routing
- Full backward compatibility: all changes gated on config.mode === 'multi'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 12:57:05 +02:00
Dries AugustynsandGitHub 57bfbf874b Merge pull request #308 from useplunk/release-please--branches--next--components--plunk 2026-03-10 20:12:14 +01:00
Dries Augustyns 1dc423a1c4 chore: Enhance image upload validation with magic byte checks 2026-03-10 20:10:13 +01:00
github-actions[bot]andGitHub de8aaa5c45 chore(next): release 0.7.1 2026-03-10 19:03:31 +00:00
Dries Augustyns 1a1e3204ee chore: Add web analytics 2026-03-10 20:03:11 +01:00
Dries Augustyns 6c4cc295b8 docs: Add inbound to docs 2026-03-08 11:11:46 +01:00
Dries Augustyns 9f212ddcb8 chore: Improve layout on mobile 2026-03-07 20:55:06 +01:00
Dries Augustyns 5f82278282 chore: Add support email in quickstart 2026-03-07 12:19:51 +01:00
Dries Augustyns f6a32ce610 docs: Improve webhook documentation 2026-03-07 09:59:41 +01:00
Dries Augustyns 0839975083 chore: Add feature pages 2026-03-06 15:13:24 +01:00
Dries Augustyns c90f3d0f01 chore: Add feature pages 2026-03-06 14:53:48 +01:00
Dries Augustyns 5f3daf689b chore: Add feature pages 2026-03-06 13:28:09 +01:00
Dries Augustyns cc7fcdb4ef fix: Align preview and actual email for templates and campaigns 2026-03-05 19:45:28 +01:00
Dries AugustynsandGitHub d75aed81e1 Merge pull request #295 from useplunk/release-please--branches--next--components--plunk 2026-03-05 11:58:38 +01:00
github-actions[bot]andGitHub 0cbaa8fc66 chore(next): release 0.7.0 2026-03-05 10:27:05 +00:00
Dries AugustynsandGitHub 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 AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-03-05 10:03:03 +00:00
Dries AugustynsandGitHub e196d798c5 Merge pull request #293 from useplunk/dependabot/npm_and_yarn/tar-7.5.9 2026-03-05 10:18:30 +01:00
Dries AugustynsandGitHub 777a12ec89 Merge pull request #296 from useplunk/dependabot/npm_and_yarn/ajv-6.14.0 2026-03-05 10:18:21 +01:00
Dries AugustynsandGitHub 6c6af775bc Merge pull request #301 from useplunk/dependabot/npm_and_yarn/rollup-4.59.0 2026-03-05 10:18:12 +01:00
Dries AugustynsandGitHub 4f8c9f029d Merge pull request #305 from useplunk/dependabot/npm_and_yarn/mailparser-3.9.3 2026-03-05 10:18:03 +01:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-03-05 07:56:38 +00:00
dependabot[bot]andGitHub 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] <support@github.com>
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]andGitHub 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] <support@github.com>
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 AugustynsandGitHub 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]andGitHub 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] <support@github.com>
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]andGitHub 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] <support@github.com>
2026-02-19 19:00:34 +00:00
Dries AugustynsandGitHub 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]andGitHub 00ae942c3c chore(next): release 0.6.0 2026-02-19 13:15:08 +00:00
Dries AugustynsandGitHub 9c69c7a36e Merge pull request #292 from RyanCasas/patch-1
feat(i18n): Add Spanish language
2026-02-19 14:14:47 +01:00
Ryan CasasandGitHub 9568b7d345 Add Spanish translations to i18n index 2026-02-19 12:16:40 +01:00
Ryan CasasandGitHub 6bba9b6199 Add Spanish localization for email preferences 2026-02-19 12:10:29 +01:00
Ryan CasasandGitHub 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 AugustynsandGitHub 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]andGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-02-14 12:57:41 +00:00
dependabot[bot]andGitHub 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] <support@github.com>
2026-02-13 20:46:34 +00:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-02-06 08:04:36 +00:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-01-29 14:12:13 +00:00
Dries AugustynsandGitHub 0af79eb84b Merge pull request #279 from useplunk/dependabot/npm_and_yarn/tar-7.5.7 2026-01-29 15:10:06 +01:00
Dries AugustynsandGitHub e2e98149cb Merge pull request #278 from useplunk/dependabot/npm_and_yarn/next-16.1.5 2026-01-29 15:09:44 +01:00
Dries AugustynsandGitHub f55ab31ae5 Merge pull request #277 from useplunk/dependabot/npm_and_yarn/lodash-4.17.23 2026-01-29 15:09:24 +01:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-01-29 14:02:08 +00:00
dependabot[bot]andGitHub 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] <support@github.com>
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]andGitHub 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] <support@github.com>
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]andGitHub 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] <support@github.com>
2026-01-20 22:25:03 +00:00
Dries AugustynsandGitHub 8de79e37e9 Merge pull request #269 from useplunk/dependabot/npm_and_yarn/undici-6.23.0 2026-01-17 20:28:36 +01:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-01-17 15:52:37 +00:00
Dries AugustynsandGitHub 22c22aba1b Merge pull request #273 from wettiemans/fix/242-template-type-dropdown 2026-01-17 12:40:18 +01:00
de106f9d91 Update apps/web/src/pages/templates/[id].tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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 AugustynsandGitHub 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 AugustynsandGitHub df28e2dc7d Merge pull request #268 from marcelscruz/next 2026-01-15 09:40:52 +01:00
dependabot[bot]andGitHub 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] <support@github.com>
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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 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 AugustynsandGitHub 73da0a5136 Merge pull request #257 from mmihalev/next 2026-01-10 10:20:57 +01:00
Milen MihalevandGitHub 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 AugustynsandGitHub b1047590e6 Merge pull request #250 from useplunk/release-please--branches--next--components--plunk 2026-01-09 08:41:30 +01:00
github-actions[bot]andGitHub 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 AugustynsandGitHub d11c4af4d1 Merge pull request #251 from useplunk/dependabot/npm_and_yarn/qs-6.14.1 2026-01-01 14:28:38 +01:00
Dries AugustynsandGitHub 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]andGitHub 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] <support@github.com>
2026-01-01 09:55:20 +00:00
Dries Augustyns 85c992a9f9 types: Abstract inline interfaces to @plunk/types 2026-01-01 10:52:43 +01:00
Dries Augustyns 38da58e5e9 types: Abstract inline interfaces to @plunk/types 2026-01-01 09:09:03 +01:00
Dries Augustyns 519b131792 chore: Rely more on services in auth middleware 2025-12-31 16:51:44 +01:00
Dries Augustyns be2eb57369 chore: Consolidate membership checks in single service 2025-12-31 16:20:17 +01:00
Dries Augustyns 19554e6e8f feat: Add platform emails for domain verification and expiration 2025-12-31 15:56:40 +01:00
Dries Augustyns 68b7e7c082 tests: fix date calculation for previous month in billing limit tests 2025-12-31 15:20:28 +01:00
Dries Augustyns 7648f6e1eb chore: Replace references to deprecated services 2025-12-31 15:08:26 +01:00
Dries Augustyns 289c633189 chore: Release please push 2025-12-30 13:29:16 +01:00
Dries Augustyns 857bb8ac9e chore: Default variables on Plunk Hosted 2025-12-30 13:28:04 +01:00
Dries AugustynsandGitHub cf6ad0b8e0 Merge pull request #241 from useplunk/release-please--branches--next--components--plunk 2025-12-29 21:23:49 +01:00
Dries Augustyns 782dc5754d chore: Add Plunk API key to entrypoint 2025-12-29 20:09:35 +01:00
Dries Augustyns cf88680a13 chore: Add Plunk API key to entrypoint 2025-12-29 20:01:18 +01:00
Dries Augustyns 6293259225 chore: Add free tools on marketing pages 2025-12-29 19:22:35 +01:00
github-actions[bot]andGitHub a8678b8842 chore(next): release 0.3.0 2025-12-29 14:52:10 +00:00
Dries Augustyns 5993b842a0 fix: Add styling for visual editor emails in preview 2025-12-29 15:51:39 +01:00
Dries Augustyns 78cef418a1 chore: Copy over manifest files 2025-12-29 15:42:18 +01:00
Dries Augustyns d56ba0cf9b chore: Resolve linting warnings 2025-12-29 15:29:29 +01:00
Dries Augustyns 72dffe12e5 feat: Email preview in contact and activity feed 2025-12-29 15:26:53 +01:00
Dries Augustyns 2d353ef839 chore: Do not dockerignore scripts 2025-12-29 14:27:04 +01:00
Dries Augustyns f26ef53c8c chore: Improve startup speed of containers 2025-12-29 14:23:21 +01:00
Dries Augustyns 98b95b2ab2 chore: Add additional comparison pages 2025-12-29 11:53:08 +01:00
Dries Augustyns bc8611a662 feat: Add additional banner and information about security metrics 2025-12-28 14:00:15 +01:00
Dries Augustyns 2ddfceca36 docs: Improve docs with core-concept and guides 2025-12-28 10:46:46 +01:00
Dries Augustyns 862babb8f5 fix: Set auth type before disable check 2025-12-27 14:40:28 +01:00
Dries Augustyns 61cea95697 fix: Correctly reserve fields from being set on contact 2025-12-25 17:59:52 +01:00
Dries Augustyns 1fc1e23fce docs: Add locale overwrite to project documentation 2025-12-25 17:53:58 +01:00
Dries Augustyns 76155232a3 feat: Ability to overwrite locale on contact level with locale key on data 2025-12-25 17:37:25 +01:00
Dries AugustynsandGitHub fa9e9de032 Merge pull request #249 from manjhss/feat/i18n-german-locale 2025-12-25 16:59:43 +01:00
Surendra Manjhi a6bd2e7dba feat(i18n): add German translations and update supported languages 2025-12-25 16:20:55 +05:30
Dries AugustynsandGitHub e58dc1d056 Merge pull request #248 from manish-raana/feat/add-hindi-translations 2025-12-25 08:43:29 +01:00
Manish Rana ddc14ae853 feat(i18n): add Hindi translations for contact-facing pages
- Add hi.json locale file with Hindi translations
- Add Hindi language to SUPPORTED_LANGUAGES
- Import Hindi translations in i18n index

Closes #246
2025-12-24 23:59:35 +05:30
Dries Augustyns 0d54b1a631 docs: Add plus address check to /v1/verify 2025-12-24 12:18:53 +01:00
Dries Augustyns afc405ec02 feat: Add plus address check to /v1/verify 2025-12-24 12:14:39 +01:00
Dries Augustyns 6a9f6aa65a feat: Add email verification endpoint at /v1/verify 2025-12-24 09:54:50 +01:00
Dries Augustyns 8f725c7c84 fix: Properly tag events in SegmentFilterBuilder.tsx 2025-12-23 10:52:03 +01:00
Dries Augustyns 97ab0a2c2c fix: Date filtering not working properly for custom contact data 2025-12-22 21:39:15 +01:00
Dries AugustynsandGitHub ddd58c20aa Merge pull request #247 from benoiteveillard/feat/i18n-french-locale 2025-12-22 10:33:29 +01:00
Benoît Eveillard 489c465599 i18n: add French (fr) locale 2025-12-22 09:46:28 +01:00
Dries Augustyns 451dd0327f fix: Import no longer case-sensitive about email column 2025-12-21 16:43:00 +01:00
Dries Augustyns e1f826357d feat: Add project-scoped language for unsubscribe footer and contact-facing pages 2025-12-21 13:09:21 +01:00
Dries Augustyns 8a136dde55 feat: Allow to pick currency when starting subscription 2025-12-21 11:53:32 +01:00
Dries Augustyns cc9b0f8a73 chore: Remove incorrect information from SEO schemas 2025-12-21 11:00:47 +01:00
Dries Augustyns 3cada63f15 chore: Correct linting errors in sitemap 2025-12-21 09:12:29 +01:00
Dries Augustyns 7fdcb22515 chore: Introduce sitemap for wiki 2025-12-21 09:07:14 +01:00
Dries Augustyns d7b5d3f60e fix: Pass through email verification if auth type is apiKey 2025-12-20 21:40:26 +01:00
Dries Augustyns 24f82b1be5 chore: Improve dropdown on mobile Navbar.tsx 2025-12-20 21:05:15 +01:00
Dries Augustyns 708dc81b88 chore: Make guide pages responsive on small screens 2025-12-20 21:03:17 +01:00
Dries Augustyns 35f5275d88 fix: Redirect verification link to dashboard instead of landing 2025-12-20 20:41:26 +01:00
Dries Augustyns 9567144390 fix: Do not check verification if platform emails are not enabled 2025-12-20 20:12:43 +01:00
Dries Augustyns 1a5607f278 feat: Add email verification and password reset 2025-12-20 20:06:25 +01:00
Dries Augustyns 7e25c148cc chore: Add index pages for /vs 2025-12-20 11:47:12 +01:00
Dries Augustyns 122713c39a chore: Resolve sitemap config by turning it into .cjs 2025-12-20 11:27:08 +01:00
Dries Augustyns 070fd812b2 chore: Ignore linting error due to useLayoutEffect 2025-12-20 11:18:46 +01:00
Dries Augustyns 4156cd436d chore: Add guides and comparison pages to landing site 2025-12-20 11:05:45 +01:00
Dries Augustyns 996852a506 chore: Additional pages 2025-12-19 19:20:30 +01:00
Dries Augustyns 2fb588357a chore: Add free tier to landing page 2025-12-19 17:25:28 +01:00
Dries Augustyns c3a276cde3 chore: Add additional links and shields 2025-12-19 14:20:10 +01:00
Dries Augustyns 661bf2a9e7 style: Remove shadow from input element for consistency 2025-12-19 13:40:52 +01:00
Dries Augustyns 007a908e83 fix: Persistence of subscription state for existing contacts 2025-12-18 19:43:59 +01:00
Dries AugustynsandGitHub 23b4ec992d Merge pull request #245 from hemanth5544/next 2025-12-18 16:13:00 +01:00
Dries Augustyns 726f66762b feat: Add bulk actions to contact overview 2025-12-18 16:12:42 +01:00
Hemanth Rachapalli 3b77ca5570 style: Remove active line background color in HtmlEditor 2025-12-18 18:44:16 +05:30
Dries Augustyns 8c0304273c fix: Variable substitution in transactional emails 2025-12-17 20:00:27 +01:00
Dries Augustyns dc9b88dedb docs: Update openapi.json to match actual API outputs 2025-12-17 08:01:17 +01:00
Dries AugustynsandGitHub 409d1b83eb Merge pull request #231 from useplunk/release-please--branches--next--components--plunk 2025-12-16 16:24:37 +01:00
github-actions[bot]andGitHub c3bd5d1d5f chore(next): release 0.2.0 2025-12-16 12:51:03 +00:00
Dries Augustyns 3336fa1e38 fix: Overflow of inputs in email editor 2025-12-16 13:50:32 +01:00
Dries Augustyns 769e3748a0 fix: Increase z-index of color picker 2025-12-16 13:19:52 +01:00
Dries Augustyns 91eb0f3a69 fix: Better highlight warnings in SecurityWarningBanner.tsx 2025-12-16 12:00:33 +01:00
Dries Augustyns 863e784e1c fix: Add additional checks for disabled projects 2025-12-16 11:52:53 +01:00
Dries Augustyns 780741e37f fix: Add additional checks for disabled projects 2025-12-15 07:41:21 +01:00
Dries Augustyns e50c33ae4b fix: Clear notification cache keys when changing billing limits 2025-12-13 10:28:11 +01:00
Dries Augustyns f34052ed73 fix: Prevent manual tracking of internal events that are automatically tracked 2025-12-12 20:30:49 +01:00
Dries Augustyns a555a12736 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:11 +01:00
Dries Augustyns f37bfccbc2 fix: Move react and react-dom to dependencies instead of peerDependency for API 2025-12-12 13:00:02 +01:00
Dries Augustyns db1496e525 chore: remove module config 2025-12-12 12:44:04 +01:00
Dries Augustyns 443c6271f1 chore: Add email package to vitest config 2025-12-12 12:38:58 +01:00
Dries Augustyns 9cf5222c8c chore: Update turbo.json and example docker-compose to include Plunk .env variables 2025-12-12 12:31:30 +01:00
Dries Augustyns 2485d2ff1d feat: Added platform emails for billing limits and disabled projects 2025-12-12 12:28:53 +01:00
Dries Augustyns cb40669385 chore: Remove accidental package-lock.json 2025-12-12 08:16:25 +01:00
Dries AugustynsandGitHub d9e7f9f5cf Merge pull request #238 from Nithur-M/next 2025-12-12 08:14:25 +01:00
Dries Augustyns dafa90f0dd chore: Also updated react and react-dom versions 2025-12-12 08:05:10 +01:00
nithur-m 9737c2b67d update to next 16.0.10 2025-12-12 09:04:57 +05:30
Dries Augustyns 1cd89d1375 fix: add additional indexes on event model 2025-12-11 19:09:54 +01:00
Dries Augustyns 11ef47b576 fix: Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages 2025-12-11 18:41:37 +01:00
Dries Augustyns fbd303801f feat: Add security center and warning for exceeding bounce/complaint rates 2025-12-11 18:34:30 +01:00
Dries AugustynsandGitHub 0ecd82d62e Merge pull request #234 from Nithur-M/next 2025-12-11 18:24:12 +01:00
nithur-m 1452cbd591 format fulldate for campaign sent time 2025-12-11 20:03:01 +05:30
Dries Augustyns 07f875c18c fix: display email progress instead of scheduling progress for campaigns 2025-12-11 14:36:26 +01:00
Dries Augustyns 5e4adb71ad fix: custom relative time to shorten strings for better UI fit 2025-12-11 11:13:39 +01:00
Dries Augustyns 1019ba0d82 feat: Improved createdAt and updatedAt visualisation 2025-12-11 10:57:56 +01:00
Dries Augustyns 6b25bbe2f8 feat: ability to create new campaigns based on templates or previous campaigns 2025-12-10 21:04:51 +01:00
Dries Augustyns b9758149f5 chore: update description in subscription card 2025-12-10 20:30:27 +01:00
Dries Augustyns d438abf1a5 Merge branch 'next' of https://github.com/useplunk/plunk into next 2025-12-10 19:53:26 +01:00
Dries Augustyns 9375c598ee chore: add indicator about campaign recipients recalculation 2025-12-10 19:52:09 +01:00
Dries AugustynsandGitHub 8f636cc5b9 Merge pull request #232 from Nithur-M/next 2025-12-10 16:07:03 +01:00
Dries Augustyns 1430f31e2d chore: replace standard logging with signale 2025-12-10 14:21:42 +01:00
Dries Augustyns 3225c5005b feat: Automatically detect rate limit from AWS with ability to override in .env 2025-12-10 14:03:30 +01:00
nithur-m f36b213f0a show text left on projects dropdown 2025-12-10 17:03:49 +05:30
Dries Augustyns cb15fad7da test: properly mock Stripe in CampaignService.test.ts 2025-12-09 19:05:22 +01:00
Dries Augustyns a3bbb1ed64 fix: prevent scheduling of campaign if billing limit reached 2025-12-09 18:50:05 +01:00
Dries Augustyns 04275e3cf5 fix: show correct default value for placeholder 2025-12-09 15:57:54 +01:00
Dries Augustyns badb035585 fix: ability to clear reply-to and from name from templates and campaigns 2025-12-09 15:47:27 +01:00
Dries Augustyns c12d0c0898 test: Correctly mock STRIPE_ENABLED 2025-12-09 15:23:48 +01:00
Dries Augustyns 6e9f11f6a7 test: Correctly mock STRIPE_ENABLED 2025-12-09 14:52:40 +01:00
Dries Augustyns 1713b2398d fix: Only check free tier limits if billing is enabled 2025-12-09 14:26:59 +01:00
Dries Augustyns 68f99798f8 fix: Verify if sending without tracking is possible in SESService 2025-12-09 14:10:47 +01:00
Dries Augustyns 5a0a41c6bb fix: Only fetch project members if project Id is defined 2025-12-09 13:24:54 +01:00
Dries Augustyns 129a496206 chore: linting error 2025-12-09 11:24:13 +01:00
Dries Augustyns d0191be31d fix: Correctly show recipient count during creation and edit 2025-12-09 11:08:53 +01:00
Dries Augustyns 85b9d7afe7 fix: Allow changing audience type after creation of campaign 2025-12-09 10:40:42 +01:00
Dries Augustyns ff5c79cfcf fix: Update recipient count on create/update of campaign 2025-12-09 10:27:56 +01:00
Dries Augustyns 672f1e6657 feat: Add improved html editor using CodeMirror 2025-12-09 10:15:33 +01:00
Dries Augustyns 433e796c04 fix: Hide upsell banner if billing is not configured 2025-12-09 08:45:53 +01:00
Dries Augustyns 77089fcdd6 style: Hide labels of tabs on smaller screens 2025-12-08 21:48:09 +01:00
Dries Augustyns a372e55b78 style: Added placeholder to EmailEditor.tsx 2025-12-08 17:45:08 +01:00
Dries Augustyns 8ec0f50f68 chore: Set version of local database to postgres-16 to align with production template 2025-12-08 17:40:24 +01:00
Dries AugustynsandGitHub dbf58046e7 Merge pull request #230 from useplunk/release-please--branches--next--components--plunk 2025-12-08 15:29:57 +01:00
github-actions[bot]andGitHub d238965c2c chore(next): release 0.1.1 2025-12-08 14:29:25 +00:00
Dries Augustyns 53ecda9f7a fix: Add additional verification in Oauth controllers 2025-12-08 15:29:04 +01:00
Dries Augustyns fc535a558f fix: Dedicated token name for next version 2025-12-08 14:31:13 +01:00
Dries Augustyns f7ac25f91f docs: Update AWS setup docs 2025-12-08 14:23:08 +01:00
Dries Augustyns 9d8b4a59fb fix: only mark releases as latest 2025-12-08 13:26:41 +01:00
Dries Augustyns 60804c12b0 fix: add clear cache button on full-screen loader 2025-12-08 13:11:59 +01:00
Dries Augustyns 90f8170efb docs: dynamic link to Docker Compose for self-hosting 2025-12-08 12:39:09 +01:00
Dries AugustynsandGitHub 1a10448af3 Merge pull request #229 from useplunk/release-please--branches--next--components--plunk 2025-12-08 11:33:06 +01:00
github-actions[bot]andGitHub e10662a1a1 chore(next): release 0.1.0 2025-12-08 08:28:37 +00:00
Dries Augustyns 6746d9e843 Only notify segment changes when amount has changed 2025-12-08 09:26:36 +01:00
Dries Augustyns 82e846e993 Add improved filtering in conditions block of workflows 2025-12-07 17:35:44 +01:00
Dries Augustyns 4d7c68db3a Use alert instead of custom component 2025-12-07 16:29:38 +01:00
Dries Augustyns 16572b8f49 Remove emojis and improve descriptions 2025-12-07 16:25:34 +01:00
Dries Augustyns caf10dce41 Move prisma to dependencies for runtime start 2025-12-07 15:24:30 +01:00
Dries Augustyns 895e0dc1a0 Fix entrypoint to halt if migrations failed 2025-12-07 15:14:56 +01:00
Dries Augustyns fd5acb9b78 Recreate migration 2025-12-07 14:52:20 +01:00
Dries Augustyns c189175658 Selectors with description 2025-12-07 14:20:44 +01:00
Dries Augustyns 683356c17c Update wiki 2025-12-07 13:36:31 +01:00
Dries Augustyns 0003e44db8 Update wiki 2025-12-07 10:25:18 +01:00
Dries Augustyns b2ecf60a13 Improve UI/UX Consistency 2025-12-06 10:06:11 +01:00
Dries Augustyns 903ea73ca2 Improve domain input loading 2025-12-06 09:45:13 +01:00
Dries Augustyns d392d69fd2 Changes to copy on landing page 2025-12-06 09:09:11 +01:00
Dries Augustyns 19258477d2 Changes to copy on landing page 2025-12-06 09:09:07 +01:00
Dries Augustyns e00fc1952b Attempt to reduce image size 2025-12-06 08:57:22 +01:00
Dries Augustyns 1a6fbbdc9a Improvements to package versioning, dependencies, etc 2025-12-06 08:16:47 +01:00
Dries Augustyns caaa58a0ff Upgrade nextjs and React to latest version 2025-12-06 08:02:52 +01:00
Dries Augustyns eac3815668 Update migration to include GIN indexes 2025-12-05 15:42:15 +01:00
Dries Augustyns 18b7d7fe98 Add billing limits display in currency 2025-12-05 12:28:23 +01:00
Dries Augustyns 9d7e8a1d1a Added correct Stripe endpoints for billing 2025-12-05 11:12:26 +01:00
Dries Augustyns ce552682c0 Upgrade Stripe to latest version 2025-12-05 09:25:00 +01:00
Dries Augustyns 7d90bef8f4 Update project name 2025-12-05 09:09:44 +01:00
Dries Augustyns f88e6f439d Update workflow with native runners 2025-12-05 08:19:04 +01:00
Dries Augustyns 04a64ef83a Added display of credits 2025-12-05 07:59:47 +01:00
Dries Augustyns 8c348d3580 Fix event propagation in sidebar 2025-12-04 21:20:40 +01:00
Dries Augustyns b4b7cc78f1 Fix linting error 2025-12-04 20:17:56 +01:00
Dries Augustyns ec70d50b39 Update billing limit tests 2025-12-04 20:10:29 +01:00
Dries Augustyns 2b65940e73 Only send bounce event every 20 emails 2025-12-04 19:44:20 +01:00
Dries Augustyns af54ab4c4e Update to create page 2025-12-04 19:36:04 +01:00
Dries Augustyns 5fc5bc68fd Add billing limit 2025-12-04 19:23:04 +01:00
Dries Augustyns 6cdbc43c8e Add ntfy.sh 2025-12-04 18:10:21 +01:00
Dries Augustyns c114feb32e Add ntfy.sh 2025-12-04 17:34:24 +01:00
Dries Augustyns 726493a1ed Add membership page 2025-12-04 15:29:30 +01:00
Dries Augustyns d08471edc2 Fix rounded corner overflow 2025-12-04 13:59:26 +01:00
Dries Augustyns a7c214f9ea Fix template page width 2025-12-04 13:57:42 +01:00
Dries Augustyns 73918f5211 Fix test cases 2025-12-04 13:17:07 +01:00
Dries Augustyns 04634a447b Fix linting errors 2025-12-04 12:46:39 +01:00
Dries Augustyns 73a1cdfefa Fix lint errors 2025-12-04 11:50:52 +01:00
Dries Augustyns fde00ebda3 Fix build errors 2025-12-04 11:42:16 +01:00
Dries Augustyns 6fe3779fcb Responsive design 2025-12-04 11:23:55 +01:00
Dries Augustyns 736672007b Refactor components to use memoization and callbacks for performance improvements 2025-12-04 10:14:18 +01:00
Dries Augustyns 3b73273629 Remove gray colours 2025-12-04 09:45:03 +01:00
Dries Augustyns 40e35735dc Add improved full screen loader 2025-12-04 09:27:14 +01:00
Dries Augustyns e4a43dfb57 Import error handling and subscribe state 2025-12-03 21:52:17 +01:00
Dries Augustyns a28be9adc9 Reset and deletion of project 2025-12-03 21:30:54 +01:00
Dries Augustyns 382e97e5b2 Refactorings, error handling and logout 2025-12-03 20:39:11 +01:00
Dries Augustyns 113b94cf2c Add activity feed to contact page 2025-12-03 19:06:18 +01:00
Dries Augustyns c1f823e2f1 Improvement to workflow creation 2025-12-03 17:36:05 +01:00
Dries Augustyns 9fadd01a6e Fix for delay actions in workflows 2025-12-03 16:22:59 +01:00
Dries Augustyns b3e6d03962 Add shared replacement method 2025-12-03 13:57:15 +01:00
Dries Augustyns 8defb9f8ab Add contact data 2025-12-03 13:28:41 +01:00
Dries Augustyns d96bce75d7 Update useContactFields to ignore type 2025-12-03 10:15:06 +01:00
Dries Augustyns de34b8c76b Add coverage to data management tab 2025-12-03 08:21:54 +01:00
Dries Augustyns e506659736 Limit to 25 emails per second 2025-12-02 13:11:27 +01:00
Dries Augustyns 2745c1bf5d Added extra analytics 2025-12-02 11:10:30 +01:00
Dries Augustyns 2ef463ce5e Added events for subscribe and unsubscribe 2025-12-02 09:56:27 +01:00
Dries Augustyns 2e6b485532 Data management tab 2025-12-02 09:46:12 +01:00
Dries Augustyns 61cb93d04c Fix linting errors 2025-12-02 09:06:40 +01:00
Dries Augustyns fe2e038037 Add better segmenting 2025-12-02 09:03:05 +01:00
Dries Augustyns b521a405bb Add events to workflows 2025-12-01 19:08:02 +01:00
Dries Augustyns b1f0043940 Add events to workflows 2025-12-01 18:37:53 +01:00
Dries Augustyns 5158756650 Automatic SNS verification 2025-12-01 16:47:06 +01:00
Dries Augustyns 62d7a61834 Automatic SNS verification 2025-12-01 16:27:19 +01:00
Dries Augustyns 54fde9675c Update tests to handle domain verification 2025-12-01 15:36:31 +01:00
Dries Augustyns e893960a85 Verify email domain before sending emails 2025-12-01 15:27:28 +01:00
Dries Augustyns 361ff6fede Added async validation 2025-12-01 15:21:12 +01:00
Dries Augustyns 1f3978e89c Added support for to and from name 2025-12-01 14:06:09 +01:00
Dries Augustyns 2dd6af8ff7 tests: Added source type to tests 2025-12-01 10:15:35 +01:00
Dries Augustyns 84caef5c45 Initial push of Plunk Next 2025-12-01 10:04:22 +01:00
Dries Augustyns 5b430fba0d Initial push of Plunk Next 2025-12-01 10:00:25 +01:00
Dries Augustyns ff1876d580 Initial push of Plunk Next 2025-12-01 09:56:56 +01:00
707 changed files with 115545 additions and 29614 deletions
+67 -5
View File
@@ -1,6 +1,68 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
.next
.turbo
out
# Development
.env
.next/
.github/
dist/
assets/
node_modules/
.env*.local
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage
.nyc_output
**/__tests__
**/*.test.ts
**/*.test.tsx
**/*.test.js
**/*.test.jsx
**/*.spec.ts
**/*.spec.tsx
**/*.spec.js
**/*.spec.jsx
test/
vitest.config.ts
# IDE
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
# Git
.git
.gitignore
.gitattributes
# CI/CD
.github
.gitlab-ci.yml
# Documentation (not needed in image)
*.md
!README.md
# Docker
Dockerfile*
docker
!docker/nginx
!docker/*.sh
.dockerignore
# Misc
.cursor
.eslintcache
.cache
tmp
temp
+166
View File
@@ -0,0 +1,166 @@
# ========================================
# Plunk Self-Hosting Configuration
# ========================================
# ========================================
# REQUIRED: Security & Database
# ========================================
DB_PASSWORD=changeme123
JWT_SECRET=
# ========================================
# REQUIRED: Domains
# ========================================
# Replace example.com with your domain
# Or use *.localhost for local testing
API_DOMAIN=api.example.com
DASHBOARD_DOMAIN=app.example.com
LANDING_DOMAIN=www.example.com
WIKI_DOMAIN=docs.example.com
# For local development: URIs for running services locally
# These are used by the applications at runtime
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
# Use placeholder URLs that will be replaced at Docker container runtime
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
# This affects how application URIs are auto-generated from domain names
USE_HTTPS=false
# ========================================
# REQUIRED: AWS SES (Email Sending)
# ========================================
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=
AWS_SES_SECRET_ACCESS_KEY=
# Configuration sets for email tracking
# SES_CONFIGURATION_SET: Default configuration with open/click tracking enabled
SES_CONFIGURATION_SET=plunk-configuration-set
# SES_CONFIGURATION_SET_NO_TRACKING: Optional configuration without tracking
# If not set, the tracking toggle will be hidden in project settings
# When set, projects can choose to disable email tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
# ========================================
# OPTIONAL: OAuth Login
# ========================================
GITHUB_OAUTH_CLIENT=
GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
# ========================================
# OPTIONAL: Stripe Billing
# ========================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING=
STRIPE_PRICE_EMAIL_USAGE=
STRIPE_METER_EVENT_NAME=emails
# ========================================
# OPTIONAL: File Storage (Minio)
# ========================================
# Minio is included by default in Docker Compose
# These credentials match the Minio service configuration
# Leave defaults unless you're using external storage
MINIO_ROOT_USER=plunk
MINIO_ROOT_PASSWORD=plunkminiopass
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# S3-compatible storage configuration
# For self-hosted: uses internal Minio service (defaults work out of the box)
S3_ENDPOINT=http://minio:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# ========================================
# OPTIONAL: Notifications (ntfy.sh)
# ========================================
# Plunk includes a self-hosted ntfy.sh server for system notifications
# You can access the ntfy web UI at http://localhost:8080 (or your configured NTFY_PORT)
# Subscribe to the topic 'plunk-notifications' to receive notifications
# Port for ntfy web UI and API (default: 8080)
NTFY_PORT=8080
# Ntfy topic URL - Uses internal ntfy service by default
# To use external ntfy.sh or your own server, change this URL
# Examples:
# - Self-hosted (default): http://ntfy/plunk-notifications
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
# - Custom server: https://your-ntfy-server.com/your-topic
NTFY_URL=http://ntfy/plunk-notifications
# ========================================
# OPTIONAL: SMTP Server
# ========================================
# The SMTP relay server allows sending emails via SMTP protocol
# TLS certificates can be mounted via:
# 1. Traefik acme.json (requires SMTP_DOMAIN to select the right cert)
# 2. PEM files (privkey.pem and fullchain.pem)
# SMTP domain - Required if using Traefik acme.json with multiple certificates
# Optional if using PEM files
SMTP_DOMAIN=smtp.example.com
# SMTP Ports (defaults work for most setups)
# PORT_SECURE=465 # SMTPS (implicit TLS)
# PORT_SUBMISSION=587 # SMTP Submission (STARTTLS)
# Maximum recipients per email (default: 5)
# MAX_RECIPIENTS=5
# ========================================
# OPTIONAL: Platform emails
# ========================================
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
# These emails include system notifications, for example when your project hits billing limits
# PLUNK_API_KEY=
# PLUNK_FROM_ADDRESS=
# ========================================
# OPTIONAL: Security Settings
# ========================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# When enabled (default), projects exceeding security limits will be automatically suspended
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
# Recommended for self-hosters: false (manage project status manually)
# Default: true
# AUTO_PROJECT_DISABLE=false
# ========================================
# 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)
# ========================================
# NGINX_PORT=80
+45
View File
@@ -0,0 +1,45 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
project: true,
tsconfigRootDir: __dirname,
ecmaVersion: 2024,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended-type-checked',
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
'next/core-web-vitals',
'prettier',
],
plugins: ['@typescript-eslint', 'react', 'jsx-a11y', 'import'],
rules: {
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_'},
],
'@typescript-eslint/consistent-type-imports': ['warn', {prefer: 'type-imports'}],
'import/order': [
'warn',
{
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling'],
'newlines-between': 'always',
'alphabetize': {order: 'asc'},
},
],
},
settings: {
react: {
version: 'detect',
},
},
ignorePatterns: ['node_modules/', 'dist/', '.next/', '*.config.js', '*.config.ts'],
};
@@ -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
+45
View File
@@ -0,0 +1,45 @@
## Description
<!-- Describe your changes in detail -->
## Type of Change
<!-- Mark the appropriate option with an 'x' -->
- [ ] `feat:` New feature (MINOR version bump)
- [ ] `fix:` Bug fix (PATCH version bump)
- [ ] `feat!:` Breaking change - new feature (MAJOR version bump)
- [ ] `fix!:` Breaking change - bug fix (MAJOR version bump)
- [ ] `docs:` Documentation update (no version bump)
- [ ] `chore:` Maintenance/dependencies (no version bump)
- [ ] `refactor:` Code refactoring (no version bump)
- [ ] `test:` Adding tests (no version bump)
- [ ] `perf:` Performance improvement (PATCH version bump)
## PR Title Format
<!--
Your PR title should follow conventional commits format:
✅ feat: add email template editor
✅ fix: resolve memory leak in worker
✅ feat!: redesign API authentication
❌ Added new feature (missing type prefix)
See VERSIONING.md for more details
-->
## Testing
<!-- Describe how you tested your changes -->
## Checklist
- [ ] PR title follows conventional commits format
- [ ] Code builds successfully
- [ ] Tests pass locally
- [ ] Documentation updated (if needed)
## Related Issues
<!-- Link any related issues here -->
Closes #
+197
View File
@@ -0,0 +1,197 @@
name: CI
on:
push:
branches:
- next
pull_request:
branches:
- next
jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
# Service containers for database, Redis, and Minio
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: plunk_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
minio:
image: minio/minio:edge-cicd
env:
MINIO_ROOT_USER: plunk
MINIO_ROOT_PASSWORD: plunkminiopass
ports:
- 9000:9000
options: >-
--health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup environment variables
run: |
cat > .env << EOF
NODE_ENV=test
# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
# Redis
REDIS_URL=redis://localhost:6379
# Security
JWT_SECRET=test-jwt-secret-for-ci-only
# S3 (Minio)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Application URLs (not needed for tests but required by schema)
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# AWS SES (mock values for tests)
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=mock
AWS_SES_SECRET_ACCESS_KEY=mock
SES_CONFIGURATION_SET=test
SES_CONFIGURATION_SET_NO_TRACKING=test-no-tracking
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
- name: Run database migrations
run: yarn workspace @plunk/db migrate:prod
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
- name: Create MinIO bucket
run: |
docker run --rm --network host \
--entrypoint /bin/sh minio/mc:latest \
-c "mc alias set local http://localhost:9000 plunk plunkminiopass && \
mc mb local/uploads --ignore-existing"
- name: Run tests
run: yarn test:run
env:
NODE_ENV: test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
coverage/
**/*.test.ts.log
retention-days: 7
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Setup minimal env (for build)
run: |
cat > .env << EOF
NODE_ENV=development
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
REDIS_URL=redis://localhost:6379
JWT_SECRET=test
# S3 (minimal - not needed but schema may require)
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=mock
S3_ACCESS_KEY_SECRET=mock
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# Application URLs
API_URI=http://localhost:8080
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
WIKI_URI=http://localhost:1000
# AWS SES (mock)
AWS_SES_REGION=us-east-1
AWS_SES_ACCESS_KEY_ID=mock
AWS_SES_SECRET_ACCESS_KEY=mock
SES_CONFIGURATION_SET=test
SES_CONFIGURATION_SET_NO_TRACKING=test
EOF
- name: Build shared packages
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Run linter
run: yarn lint
- name: Type check
run: yarn build --filter="api" --filter="web" --filter="landing" --filter="wiki" || true
-32
View File
@@ -1,32 +0,0 @@
name: Build and Push Docker image (Canary)
on:
push:
branches:
- canary
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
driaug/plunk:canary
platforms: linux/amd64,linux/arm64
-37
View File
@@ -1,37 +0,0 @@
name: Build and Push Docker image (Production)
on:
push:
branches:
- main
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Get version from package.json
id: get_version
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: |
driaug/plunk:${{ env.VERSION }}
driaug/plunk:latest
platforms: linux/amd64,linux/arm64
+225
View File
@@ -0,0 +1,225 @@
name: Docker Build and Publish
on:
push:
branches:
- next
jobs:
# Prepare metadata and tags for all builds
prepare:
runs-on: ubuntu-latest
outputs:
is_release: ${{ steps.check-release.outputs.is_release }}
release_tag: ${{ steps.check-release.outputs.release_tag }}
tags: ${{ steps.tags.outputs.tags }}
image: ${{ steps.tags.outputs.image }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if this commit is a release
id: check-release
run: |
git fetch --tags
# Check if this is a release-please commit
if git log -1 --pretty=%B | grep -q "release-please--branches--next"; then
echo "This is a release commit, waiting for tag..."
# Wait up to 60 seconds for release-please to create the tag
for i in {1..12}; do
sleep 5
git fetch --tags
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
if [[ -n "$TAG" ]]; then
echo "is_release=true" >> $GITHUB_OUTPUT
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
echo "Found release tag: $TAG"
exit 0
fi
echo "Waiting for tag... ($i/12)"
done
echo "ERROR: Release commit but no tag found after 60s"
exit 1
else
# Regular commit, check for tag immediately
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
if [[ -n "$TAG" ]]; then
echo "is_release=true" >> $GITHUB_OUTPUT
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
echo "This is a release: $TAG"
else
echo "is_release=false" >> $GITHUB_OUTPUT
echo "This is a regular commit"
fi
fi
- name: Compute tags
id: tags
run: |
IMAGE="ghcr.io/${{ github.repository }}"
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
# This is a release: use semver tags + latest
TAG="${{ steps.check-release.outputs.release_tag }}"
VERSION="${TAG#v}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
echo "Building RELEASE with tags: $TAGS"
else
# Regular commit: use SHA tags only (no latest)
SHORT_SHA="${GITHUB_SHA:0:7}"
TAGS="sha-${SHORT_SHA}"
echo "Building COMMIT with tags: $TAGS"
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "image=$IMAGE" >> $GITHUB_OUTPUT
# Build natively on each platform (faster, no QEMU emulation)
build:
needs: prepare
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Turborepo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-turbo-
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
docker system prune -af --volumes
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ needs.prepare.outputs.image }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
push: true
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ needs.prepare.outputs.image }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
build-args: |
BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VERSION=${{ github.ref_name }}
REVISION=${{ github.sha }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ strategy.job-index }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Merge platform-specific images into multi-arch manifest
merge:
needs: [ prepare, build ]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
# Convert comma-separated tags to array and create docker tag arguments
TAG_ARGS=""
IFS=',' read -ra TAG_ARRAY <<< "$TAGS"
for tag in "${TAG_ARRAY[@]}"; do
TAG_ARGS="$TAG_ARGS -t ${IMAGE}:${tag}"
done
# Create and push manifest using all digests
docker buildx imagetools create $TAG_ARGS \
$(printf '${{ needs.prepare.outputs.image }}@sha256:%s ' *)
- name: Inspect image
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
FIRST_TAG=$(echo "$TAGS" | cut -d',' -f1)
docker buildx imagetools inspect ${IMAGE}:${FIRST_TAG}
- name: Summary
run: |
IMAGE="${{ needs.prepare.outputs.image }}"
TAGS="${{ needs.prepare.outputs.tags }}"
echo "## Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.prepare.outputs.is_release }}" == "true" ]]; then
echo "**Type:** Release (${{ needs.prepare.outputs.release_tag }})" >> $GITHUB_STEP_SUMMARY
else
echo "**Type:** Commit (sha-${GITHUB_SHA:0:7})" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$TAGS" | tr ',' '\n' | sed "s|^|${IMAGE}:|" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
+25
View File
@@ -0,0 +1,25 @@
name: Release Please
on:
push:
branches:
- next
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Release Please
uses: googleapis/release-please-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
target-branch: next
+47 -8
View File
@@ -1,12 +1,51 @@
# Dependencies
node_modules
dist
.idea
.vscode
*.log
.pnp
.pnp.js
# Local env files
.env
.tscache
.next
.out
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
.source/
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
.yarn/install-state.gz
*.pem
.idea/
.vscode/
robots.txt
*sitemap*.xml
.contentlayer
.content-collections
.source
install-state.gz
tsconfig.tsbuildinfo
next-env.d.ts
+10
View File
@@ -0,0 +1,10 @@
{
"printWidth": 120,
"quoteProps": "consistent",
"arrowParens": "avoid",
"singleQuote": true,
"bracketSpacing": false,
"useTabs": false,
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
}
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.8.0"
}
-894
View File
File diff suppressed because one or more lines are too long
+948
View File
File diff suppressed because one or more lines are too long
+7 -1
View File
@@ -1,3 +1,9 @@
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.3.0.cjs
# Support multiple architectures - download pre-built binaries instead of compiling
supportedArchitectures:
os: [ "linux", "darwin", "win32" ]
cpu: [ "x64", "arm64" ]
libc: [ "glibc" ]
yarnPath: .yarn/releases/yarn-4.9.1.cjs
+311
View File
@@ -0,0 +1,311 @@
# Changelog
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
### Features
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
### Bug Fixes
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
### Documentation
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
### Bug Fixes
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
### Documentation
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
## [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)
### Features
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
### Bug Fixes
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
### Documentation
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
### Features
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
### Bug Fixes
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
### Bug Fixes
* Add additional verification in Oauth controllers ([53ecda9](https://github.com/useplunk/plunk/commit/53ecda9f7a34111785ac3ea3af18cb33460a44af))
* add clear cache button on full-screen loader ([60804c1](https://github.com/useplunk/plunk/commit/60804c12b0b4c4c3ef97e730e4857d41fa1fd021))
* Dedicated token name for next version ([fc535a5](https://github.com/useplunk/plunk/commit/fc535a558fab28045dae9ce978f483e58c72a24f))
* only mark releases as latest ([9d8b4a5](https://github.com/useplunk/plunk/commit/9d8b4a59fbd42420bb595d2a44df93a29214121a))
### Documentation
* dynamic link to Docker Compose for self-hosting ([90f8170](https://github.com/useplunk/plunk/commit/90f8170efbad0cec981a24e7831840aac65d8d70))
* Update AWS setup docs ([f7ac25f](https://github.com/useplunk/plunk/commit/f7ac25f91f4e1dcce301fb325801a67f2a9dee07))
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08)
### Features
* Added ability to overwrite sender mail and name for templates and campaigns ([f1d4d50](https://github.com/useplunk/plunk/commit/f1d4d5073ccd7ddceabfdbd268c5ff142480fb75))
* basic health-checks ([2e69173](https://github.com/useplunk/plunk/commit/2e69173d1378bec8296ce556499f71990ee92375))
* **dashboard:** add status filter for contacts ([45697cf](https://github.com/useplunk/plunk/commit/45697cf6b122266ec3ab35e51e064db48517448d))
* Email attachment support ([0e718d4](https://github.com/useplunk/plunk/commit/0e718d4dba1c8a258eb949cc0590ee14046c080a))
* Increase pagination limits to improve data display and update version to 1.2.1 ([4fe90ad](https://github.com/useplunk/plunk/commit/4fe90adf1e97c85afddeae0ad9e1ecc11dd0609b))
* Optimize data retrieval and add new indexes for performance improvements ([51b40cc](https://github.com/useplunk/plunk/commit/51b40cc06962ad98333a50ac7b4dbd82d8a3fe10))
### Bug Fixes
* Add fixed yarn version ([daa8285](https://github.com/useplunk/plunk/commit/daa8285af59c54c302b805e1d5afa0106567bbf4))
* Add fixed yarn version to Docker build ([ff5c6af](https://github.com/useplunk/plunk/commit/ff5c6af9d730a80d037a84454074dedda5709862))
* Add ref ([4c25a28](https://github.com/useplunk/plunk/commit/4c25a28d69f7f3719b1180924ef1c26958757ba5))
* Add target ([8ca5aea](https://github.com/useplunk/plunk/commit/8ca5aea0ea34f530595c7ec4e1f9b7514ba4e1a9))
* Added Migration ([e2d0d0e](https://github.com/useplunk/plunk/commit/e2d0d0eb0d2989e2668506c48fe55c0e41b29da3))
* Added try/catch to CRON ([c96a007](https://github.com/useplunk/plunk/commit/c96a0074f87f084801289cc9852f27221e4720e9))
* Check if APP_URI has https ([bad18ae](https://github.com/useplunk/plunk/commit/bad18ae559d582181d8c298f3c537c50b4b08851))
* clear Redis key on event deletion ([0ef39ce](https://github.com/useplunk/plunk/commit/0ef39ce3d2ad90be8e83ab97d5076f90642f3cc6))
* Correctly duplicate from ([111e054](https://github.com/useplunk/plunk/commit/111e054fe88997201ebf012602223c92f65d21a6))
* Force node-20 ([0b79bef](https://github.com/useplunk/plunk/commit/0b79bef2cb79cd3f8073884c9b84df733a696bad))
* incorrect domain verification records ([4bb38b9](https://github.com/useplunk/plunk/commit/4bb38b9f43cf495cf08ec6d95466e1b8cea409ce))
* incorrect schema in dashboard ([d69a57d](https://github.com/useplunk/plunk/commit/d69a57d71150e52e12cd1ed475dd16353d5ff9c1))
* move updating the contact to contacts controller ([7c8d3bb](https://github.com/useplunk/plunk/commit/7c8d3bb050fb0c35e0296eeb71d516f0c37329a2))
* Proper reset of contact ([7879e66](https://github.com/useplunk/plunk/commit/7879e6631e8a06020753d7a78583d4a1404b7684))
* refetch contact after awaiting triggers ([7202606](https://github.com/useplunk/plunk/commit/7202606aef0081a1e68f91df3b2e94e67e44ca39))
* refetch contact after awaiting triggers ([3f70be9](https://github.com/useplunk/plunk/commit/3f70be95dde80c04611089f66b1fcaa8eb30655e))
* Remove openssl install ([605a5a0](https://github.com/useplunk/plunk/commit/605a5a0abfbf0d11076440afc07d2f9b2d1ee071))
* return updated contact info ([115ac5f](https://github.com/useplunk/plunk/commit/115ac5fe6c87181fe33de367d88d01ef872ed2af))
* server crash in self-hosted instances ([ccb832d](https://github.com/useplunk/plunk/commit/ccb832d095de7d7b65ee07743ba8cf8025b86948))
* Show project `from` and `name` as backup ([d4bdcbf](https://github.com/useplunk/plunk/commit/d4bdcbf78ecfe7913128f8c5c90e36ee60eb6dca))
* Unlink domain properly executes ([f58149a](https://github.com/useplunk/plunk/commit/f58149a1b168cf931c2a2768345f2a8213fe27a1))
* Update yarn.lock ([ee1651e](https://github.com/useplunk/plunk/commit/ee1651e3902d879047082864a459ff63f7bee57d))
* Version ([5ca4567](https://github.com/useplunk/plunk/commit/5ca45677ffa181b9d288e42f69dc90a2498342af))
* Version ([3a5d05a](https://github.com/useplunk/plunk/commit/3a5d05a65ba38d4abe5f70be4fe1bd195290e055))
* Version ([42e865c](https://github.com/useplunk/plunk/commit/42e865ccbab7799c535a3c18ade4caddffd88f72))
* Version ([b742ab1](https://github.com/useplunk/plunk/commit/b742ab10eb34c52fbd9b9af32e36e3784c2d6fc8))
### Code Refactoring
* remove recipients from campaign data structure ([c40189e](https://github.com/useplunk/plunk/commit/c40189ebbdb80c7e24881191cb1b750d9faffbc1))
+172
View File
@@ -0,0 +1,172 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Plunk is a Turborepo monorepo containing multiple applications and shared packages for a platform service. The project
uses Yarn workspaces with Node.js 20+ requirement.
## Scale & Performance Requirements
**CRITICAL**: This service operates at high scale with a large amount of contacts being added every day. All code
changes must consider:
- **Database Performance**: Queries must be optimized for large datasets (1M+ rows). Avoid N+1 queries, use proper
indexes, and prefer cursor-based pagination over offset-based.
- **Memory Efficiency**: Never load large datasets into memory. Always use streaming or batch processing with reasonable
limits.
- **Asynchronous Operations**: Heavy computations (counts, aggregations, bulk updates) should be offloaded to background
jobs via BullMQ, not executed synchronously in API requests.
- **Caching Strategy**: Frequently accessed computed values should be cached or stored as materialized data to avoid
repeated expensive queries.
- **Query Optimization**: Be mindful of JSON field queries (Contact.data) - these require GIN indexes. Test query plans
with EXPLAIN ANALYZE.
- **API Response Times**: Target < 200ms for read operations, < 500ms for write operations. Use timeouts and circuit
breakers.
When implementing features that query or process contacts, segments, or campaigns:
1. Always consider performance with millions of contacts
2. Use pagination with reasonable defaults (20-100 items)
3. Implement background jobs for bulk operations
4. Add database indexes for new query patterns
5. Cache computed values that don't need real-time accuracy
## Development Commands
### Environment Setup
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, Minio, and Browserless via Docker Compose
- **Build shared packages**: `yarn build --filter="@plunk/shared"` - Required before running apps
### Development
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
- **Build all**: `yarn build`
- **Lint all**: `yarn lint`
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
**Note**: The API's `dev` script automatically runs both the server and worker process using `concurrently`. If you need
to run them separately (e.g., for debugging), use `dev:server` and `dev:worker` individually.
### Database (Prisma)
- **Generate client**: `yarn workspace @plunk/db db:generate`
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
- **Deploy migrations (prod)**: `yarn workspace @plunk/db migrate:prod`
## Architecture
### Applications (`apps/`)
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
- HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
### Background Job Architecture
The API uses BullMQ (backed by Redis) for asynchronous job processing:
- **API Server** creates jobs and adds them to queues (email, campaign, workflow)
- **Worker Process** (`apps/api/src/jobs/worker.ts`) consumes jobs from queues
- Jobs are processed with retry logic, rate limiting, and concurrency control
- Worker runs separately for scalability and fault isolation (can scale workers independently)
### Shared Packages (`packages/`)
- **@plunk/db**: Prisma schema and client
- **@plunk/ui**: ShadCN-based UI library with Radix UI + Tailwind
- **@plunk/shared**: Common utilities and business logic
- **@plunk/types**: TypeScript type definitions
- **@plunk/email**: React-email templates
- **@plunk/notifications**: Notification system
## Key Technologies
- **Frontend**: React 19, Next.js 15.3, Tailwind CSS, Framer Motion
- **Backend**: Express.js, Prisma, Redis (ioredis), Stripe
- **UI Library**: Radix UI primitives, ShadCN components
- **Authentication**: JWT with bcrypt
## Code Standards
### Import Organization
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
between groups.
### TypeScript
- Consistent type imports preferred: `import type { ... }`
- Unused vars allowed with `_` prefix
- Strict type checking enabled across all packages
- Try to avoid inline types in favor of shared types in `@plunk/types`
### Component Structure
- UI components in `packages/ui/src/components/`
- App-specific components in `apps/<app>/src/components/`
- Atomic design pattern: atoms → molecules hierarchy
## Environment Variables
**Configuration File Setup:**
- **Development**: Copy `.env.example` to `.env` at the repository root and fill in your values
- **All apps** (API, web, landing, wiki) load environment variables from the root `.env` file
- **Production**: Environment variables are injected by Docker/orchestration systems (no .env file needed)
Required for builds and deployment (see turbo.json and .env.example):
**Build Time:**
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL` (for Prisma client generation)
- Standard: `NODE_ENV`
**Runtime:**
- Security: `JWT_SECRET`
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL`
- Infrastructure: `REDIS_URL`
- **Application URLs** (injected at runtime into Next.js apps): `API_URI`, `DASHBOARD_URI`, `LANDING_URI`, `WIKI_URI` (
optional)
- S3-compatible Storage (Minio): `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_ACCESS_KEY_SECRET`, `S3_BUCKET`,
`S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`
- AWS SES: `AWS_SES_REGION`, `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `SES_CONFIGURATION_SET`,
`SES_CONFIGURATION_SET_NO_TRACKING`
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
`STRIPE_METER_EVENT_NAME`
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
- 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:**
- **Development**: All environment variables are loaded from the root `.env` file (monorepo-wide)
- **Production**: The application URLs (`API_URI`, `DASHBOARD_URI`, etc.) are injected at Docker container startup. This
allows the same Docker image to be used across different environments by simply changing environment variables at
runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access
## 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.
+131 -16
View File
@@ -1,29 +1,144 @@
# Contributing
# Contributing to Plunk
You can greatly support Plunk by contributing to this repository.
Thank you for your interest in contributing to Plunk! This guide will help you get started with development.
Support can be asked in the `#contributions` channel of the [Plunk Discord server](https://useplunk.com/discord)
## Architecture
### 1. Requirements
Plunk V2 is built as a modern Turborepo monorepo with the following structure:
- Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system.
### Applications (`apps/`)
### 2. Install dependencies
- **api**: Express.js API server with background worker process (BullMQ)
- **web**: Next.js dashboard application (app.useplunk.com)
- **landing**: Next.js marketing site (www.useplunk.com)
- **wiki**: Next.js documentation site (docs.useplunk.com)
- Run `yarn install` to install the dependencies.
### Shared Packages (`packages/`)
### 3. Set your environment variables
- **@plunk/db**: Prisma database schema and client
- **@plunk/ui**: Shared UI components (ShadCN + Radix UI)
- **@plunk/shared**: Common utilities and business logic
- **@plunk/types**: TypeScript type definitions
- **@plunk/email**: React Email templates
- Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
- Set AWS credentials in the `api` `.env` file.
### Technology Stack
### 4. Start resources
- **Frontend**: React 19, Next.js 15, Tailwind CSS, Framer Motion
- **Backend**: Express.js, TypeScript (ESM), Prisma ORM
- **Database**: PostgreSQL with connection pooling
- **Cache/Queue**: Redis, BullMQ for background jobs
- **Email Delivery**: AWS SES with bounce/complaint handling
- **Storage**: S3-compatible (MinIO, AWS S3)
- **Payments**: Stripe with usage-based billing
- Run `yarn services:up` to start a local database and a local redis server.
- Run `yarn migrate` to apply the migrations to the database.
- Run `yarn build:shared` to build the shared package.
## Development Setup
For local development without Docker:
- Run `yarn dev:api` to start the API server.
- Run `yarn dev:dashboard` to start the dashboard server.
### Prerequisites
- Node.js 20+
- Yarn 4.9+
- Docker & Docker Compose (for services)
### Quick Start
```bash
# Clone and install
git clone <repo-url>
cd app
yarn install
# Start infrastructure services (PostgreSQL, Redis, MinIO)
yarn services:up
# Set up environment variables
cp .env.example .env
# Edit .env with your configuration
# Run database migrations
yarn workspace @plunk/db migrate:dev
# Start all development servers
yarn dev
```
**Note**: The `dev` command starts the API server, worker process, and web apps. For production-like setup or debugging,
you can run components separately:
```bash
# Terminal 1: API Server
yarn workspace api dev:server
# Terminal 2: Background Worker (required for emails)
yarn workspace api dev:worker
# Terminal 3: Web Dashboard
yarn workspace web dev
```
## Development Commands
### Environment Setup
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, MinIO via Docker Compose
- **Stop services**: `yarn services:down` - Stops all infrastructure services
### Development
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
- **Build all**: `yarn build`
- **Lint all**: `yarn lint`
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
### Database (Prisma)
- **Generate client**: `yarn workspace @plunk/db db:generate`
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
## Code Standards
### Import Organization
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
between groups.
### TypeScript
- Use TypeScript for all new code
- Prefer type imports: `import type { ... }`
- Enable strict type checking
### Component Structure
- UI components go in `packages/ui/src/components/`
- App-specific components in `apps/<app>/src/components/`
- Follow atomic design pattern where applicable
## Making Changes
1. **Fork the repository** and create a new branch from `main`
2. **Make your changes** following the code standards above
3. **Test your changes** thoroughly
4. **Commit your changes** with clear, descriptive commit messages
5. **Push to your fork** and submit a pull request
## Pull Request Guidelines
- Provide a clear description of the changes
- Reference any related issues
- Ensure all tests pass and linting is clean
- Keep PRs focused on a single feature or fix
## Need Help?
- Check the [documentation](https://docs.useplunk.com)
- Open an issue for bugs or feature requests
- Join our community discussions
## License
By contributing to Plunk, you agree that your contributions will be licensed under the AGPL-3.0 License.
+347 -23
View File
@@ -1,35 +1,359 @@
# Base Stage
FROM node:20-alpine3.20 AS base
# Multi-stage Dockerfile for Plunk
# Creates a single image containing all applications (API, Worker, Web, Landing, Wiki)
# Use SERVICE environment variable to specify which service to run
# ============================================
# Stage 1: Dependencies (All dependencies for building)
# ============================================
# Use build platform (AMD64) to install dependencies, avoiding QEMU issues
FROM --platform=$BUILDPLATFORM node:20-slim AS deps
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy Yarn configuration and release
COPY .yarnrc.yml ./
COPY .yarn/releases ./.yarn/releases
# Copy package files for dependency installation
COPY package.json yarn.lock ./
# Copy workspace package.json files
COPY apps/api/package.json ./apps/api/
COPY apps/smtp/package.json ./apps/smtp/
COPY apps/web/package.json ./apps/web/
COPY apps/landing/package.json ./apps/landing/
COPY apps/wiki/package.json ./apps/wiki/
COPY packages/db/package.json ./packages/db/
COPY packages/ui/package.json ./packages/ui/
COPY packages/shared/package.json ./packages/shared/
COPY packages/types/package.json ./packages/types/
COPY packages/email/package.json ./packages/email/
COPY packages/typescript-config/package.json ./packages/typescript-config/
COPY packages/eslint-config/package.json ./packages/eslint-config/
# Copy wiki source files for postinstall script (fumadocs-mdx)
COPY apps/wiki/content ./apps/wiki/content
COPY apps/wiki/lib ./apps/wiki/lib
COPY apps/wiki/source.config.ts ./apps/wiki/source.config.ts
COPY apps/wiki/mdx-components.tsx ./apps/wiki/mdx-components.tsx
COPY apps/wiki/next.config.mjs ./apps/wiki/next.config.mjs
COPY apps/wiki/tsconfig.json ./apps/wiki/tsconfig.json
# Install dependencies (runs on build platform, fetches binaries for target platform)
# Use cache mounts for Yarn cache to speed up dependency installation
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" && \
yarn install --immutable
# ============================================
# Stage 1b: Production Dependencies for API/SMTP
# ============================================
# Install only production dependencies needed for API and SMTP services
FROM --platform=$BUILDPLATFORM node:20-slim AS prod-deps
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy Yarn configuration
COPY .yarnrc.yml ./
COPY .yarn/releases ./.yarn/releases
# Copy all package.json files (needed for workspace resolution)
COPY package.json yarn.lock ./
COPY apps/api/package.json ./apps/api/
COPY apps/smtp/package.json ./apps/smtp/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
COPY packages/types/package.json ./packages/types/
COPY packages/email/package.json ./packages/email/
# Install ONLY production dependencies for api, smtp, and their workspace dependencies
# This excludes devDependencies and unneeded workspaces (web, landing, wiki, ui)
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
echo "Installing production dependencies for API/SMTP on $BUILDPLATFORM for $TARGETPLATFORM" && \
yarn workspaces focus api smtp --production
# ============================================
# Stage 2: Builder
# ============================================
# Builder runs on target platform to generate platform-specific artifacts
FROM node:20-slim AS builder
ARG TARGETPLATFORM
# Build-time arguments for URL configuration
# These are only used during the build process (for wiki OpenAPI generation and static assets)
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
ARG API_URI=https://next-api.useplunk.com
ARG DASHBOARD_URI=https://next-app.useplunk.com
ARG LANDING_URI=https://www.useplunk.com
ARG WIKI_URI=https://docs.useplunk.com
WORKDIR /app
# Install OpenSSL for Prisma
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
# Enable Corepack and set Yarn version
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
# Copy dependencies from deps stage
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.yarn ./.yarn
COPY --from=deps /app/.yarnrc.yml ./
COPY --from=deps /app/package.json ./
COPY --from=deps /app/yarn.lock ./
# ============================================
# OPTIMIZATION: Copy and build in layers for better Docker cache utilization
# Docker will only rebuild from the first changed layer, not everything
# ============================================
# Copy root config files needed for Turbo
COPY turbo.json ./
# Copy manifest generation script
COPY docker/generate-url-manifest.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
# Step 1: Copy and build shared packages (these change less frequently)
# Shared packages are dependencies for apps, so build them first
COPY packages ./packages
RUN yarn workspace @plunk/db db:generate
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter="@plunk/*"
# Step 2: Copy and build API (backend services)
COPY apps/api ./apps/api
COPY apps/smtp ./apps/smtp
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=api --filter=smtp
# Step 3: Copy and build Wiki (includes API doc generation)
COPY apps/wiki ./apps/wiki
# Generate OpenAPI spec and docs (URLs will be placeholder values for runtime replacement)
RUN cd apps/wiki && \
node scripts/generate-openapi.js && \
npx tsx scripts/generate-docs.mts && \
npx fumadocs-mdx && \
cd ../..
# Build wiki with placeholder URLs (replaced at container startup)
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=wiki
# Generate sitemap for wiki
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
# Generate URL replacement manifest for wiki (build-time optimization)
RUN generate-url-manifest.sh wiki /app/apps/wiki
# Step 4: Copy and build Web dashboard
COPY apps/web ./apps/web
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=web
# Generate sitemap for web
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
# Generate URL replacement manifest for web (build-time optimization)
RUN generate-url-manifest.sh web /app/apps/web
# Step 5: Copy and build Landing page
COPY apps/landing ./apps/landing
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
API_URI=${API_URI} \
DASHBOARD_URI=${DASHBOARD_URI} \
LANDING_URI=${LANDING_URI} \
WIKI_URI=${WIKI_URI} \
NEXT_PUBLIC_API_URI=${API_URI} \
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
yarn turbo build --filter=landing
# Generate sitemap for landing
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
# Generate URL replacement manifest for landing (build-time optimization)
RUN generate-url-manifest.sh landing /app/apps/landing
# Copy any remaining root files (if needed)
COPY . .
ARG NEXT_PUBLIC_API_URI=PLUNK_API_URI
RUN yarn install --network-timeout 1000000
RUN yarn build:shared
RUN yarn workspace @plunk/api build
RUN yarn workspace @plunk/dashboard build
# Final Stage
FROM node:20-alpine3.20
# Ensure directories exist (create empty ones if build didn't generate them)
RUN mkdir -p \
apps/web/public \
apps/landing/public \
apps/wiki/public \
apps/web/.next/standalone \
apps/landing/.next/standalone \
apps/wiki/.next/standalone
# ============================================
# Stage 3: Production Runtime
# ============================================
FROM node:20-alpine AS runner
ARG TARGETPLATFORM
ARG BUILDPLATFORM
WORKDIR /app
RUN apk add --no-cache bash nginx
# Install OpenSSL for Prisma, curl for health checks, nginx, and gettext (for envsubst)
RUN apk add --no-cache openssl curl nginx gettext
COPY --from=base /app/packages/api/dist /app/packages/api/
COPY --from=base /app/packages/dashboard/.next /app/packages/dashboard/.next
COPY --from=base /app/packages/dashboard/public /app/packages/dashboard/public
COPY --from=base /app/node_modules /app/node_modules
COPY --from=base /app/packages/shared /app/packages/shared
COPY --from=base /app/prisma /app/prisma
COPY deployment/nginx.conf /etc/nginx/nginx.conf
COPY deployment/entry.sh deployment/replace-variables.sh /app/
# Install PM2 globally for process management
# Use cache mount and specific version to prevent hangs
RUN --mount=type=cache,target=/root/.npm \
npm install -g pm2@5.4.2 --prefer-offline --no-audit
RUN chmod +x /app/entry.sh /app/replace-variables.sh
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 plunk
EXPOSE 3000 4000 5000
# Create nginx directories and set permissions
RUN mkdir -p /var/log/nginx /var/lib/nginx /run/nginx && \
chown -R plunk:nodejs /var/log/nginx /var/lib/nginx /run/nginx /etc/nginx
CMD ["sh", "/app/entry.sh"]
# ============================================
# Copy API and SMTP services with minimal dependencies
# ============================================
# Copy built API and SMTP services
COPY --from=builder --chown=plunk:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/dist ./apps/smtp/dist
# Copy ONLY production dependencies for API/SMTP (excludes dev deps and frontend packages)
COPY --from=prod-deps --chown=plunk:nodejs /app/node_modules ./node_modules
# Copy Prisma client from builder (includes generated client with correct platform binaries)
COPY --from=builder --chown=plunk:nodejs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder --chown=plunk:nodejs /app/node_modules/@prisma ./node_modules/@prisma
# Copy only the shared packages that are built (not source files)
# These are needed by API/SMTP at runtime
COPY --from=builder --chown=plunk:nodejs /app/packages/db/dist ./packages/db/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/db/package.json ./packages/db/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/shared/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
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)
COPY --from=builder --chown=plunk:nodejs /app/packages/db/prisma ./packages/db/prisma
# Copy root package.json and workspace config (needed for yarn workspace commands in entrypoint)
COPY --from=builder --chown=plunk:nodejs /app/package.json ./
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarnrc.yml ./
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarn ./.yarn
COPY --from=prod-deps --chown=plunk:nodejs /app/yarn.lock ./
# Copy API/SMTP package.json files
COPY --from=builder --chown=plunk:nodejs /app/apps/api/package.json ./apps/api/
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp/
# ============================================
# Copy Next.js apps with their standalone builds
# ============================================
# Next.js standalone mode bundles all dependencies internally, so we don't need
# to copy node_modules for these apps - they're completely self-contained
# Web app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
# Landing app - standalone build with static assets
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
# Wiki app - standalone build with static assets and OpenAPI spec
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
# Copy URL replacement manifests to standalone directory
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
# Copy full .next directories for the entrypoint script (URL replacement via find command)
# These are much smaller than node_modules and needed for runtime URL replacement
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next ./apps/web/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next ./apps/landing/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next ./apps/wiki/.next
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/openapi.local.json
# ============================================
# Copy runtime configuration
# ============================================
# Copy nginx configuration templates and setup script
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
RUN chmod +x /app/docker/nginx/setup-nginx.sh
# Copy optimized URL replacement script
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
RUN chmod +x /app/docker/replace-urls-optimized.sh
# Copy entrypoint script
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
USER plunk
# Expose nginx port (default 80), SMTP ports (465, 587)
# Port 80 is also used for ACME HTTP-01 challenges
EXPOSE 80 465 587
# Health check through nginx
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Default to running all services via entrypoint
ENV SERVICE=all
ENTRYPOINT ["docker-entrypoint-nginx.sh"]
+25 -24
View File
@@ -7,11 +7,14 @@
</p>
<p align="center">
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
<img src="https://img.shields.io/github/actions/workflow/status/driaug/plunk-whitelabel/docker-build-prod.yml"/>
<img src="https://img.shields.io/docker/pulls/driaug/plunk"/>
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
</p>
## Introduction
@@ -23,36 +26,34 @@ like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun
## Features
- **Transactional Emails**: Send emails straight from your API
- **Automations**: Create automations based on user actions
- **Broadcasts**: Send newsletters and product updates to big audiences
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
## Sponsors
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting via [GitHub Sponsors](https://github.com/sponsors/driaug).
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="https://avatars.githubusercontent.com/u/206509599?s=200&v=4" style="width:80px; height:80px; object-fit:contain;"/><br/>
<a href="https://every.news/?ref=useplunk.com">Everynews</a>
</td>
</tr>
</table>
</div>
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting
via [GitHub Sponsors](https://github.com/sponsors/driaug).
## Self-hosting Plunk
The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image.
You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/).
The easiest way to self-host Plunk is by using the `plunk` Docker image.
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
A complete guide on how to deploy Plunk can be found in
the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
## Contributing
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
<a href="https://github.com/useplunk/plunk/graphs/contributors">
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
</a>
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
</a>
## License
AGPL-3.0 License - see [LICENSE](LICENSE) for details.
+87
View File
@@ -0,0 +1,87 @@
# ==============================================================================
# Development Environment Configuration
# ==============================================================================
# This is for local development. For production/self-hosting, see .env.self-host.example
# ==============================================================================
# Environment & Security
# ==============================================================================
NODE_ENV=development
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
# ==============================================================================
# Application URLs
# ==============================================================================
# Set to 'true' for HTTPS in production (only used when auto-generating URIs from domains)
USE_HTTPS=false
API_URI=http://localhost:8080
WIKI_URI=http://localhost:1000
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
# ==============================================================================
# Plunk API (Optional - for sending emails via Plunk API)
# ==============================================================================
# API key for authenticating with the Plunk API (obtained from dashboard)
PLUNK_API_KEY=
# ==============================================================================
# Database & Redis
# ==============================================================================
REDIS_URL=redis://127.0.0.1:56379
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
# ==============================================================================
# S3-compatible Storage (Minio - for file uploads)
# ==============================================================================
# For local development, make sure Minio is running via docker compose
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=plunk
S3_ACCESS_KEY_SECRET=plunkminiopass
S3_BUCKET=uploads
S3_PUBLIC_URL=http://localhost:9000/uploads
S3_FORCE_PATH_STYLE=true
# ==============================================================================
# AWS SES (Required - for sending emails)
# ==============================================================================
AWS_SES_REGION=eu-north-1
AWS_SES_ACCESS_KEY_ID=
AWS_SES_SECRET_ACCESS_KEY=
# Configuration sets for email tracking
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
# Email sending rate limit (emails per second)
# If not set, automatically fetches from AWS SES account quota (recommended)
# Set this to override AWS quota (useful for setting lower limits or testing)
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
# EMAIL_RATE_LIMIT_PER_SECOND=14
# ==============================================================================
# OAuth (Optional - for social login)
# ==============================================================================
GITHUB_OAUTH_CLIENT=
GITHUB_OAUTH_SECRET=
GOOGLE_OAUTH_CLIENT=
GOOGLE_OAUTH_SECRET=
# ==============================================================================
# Stripe (Optional - for billing)
# ==============================================================================
STRIPE_SK=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
# ==============================================================================
# Security (Optional)
# ==============================================================================
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
# Default: true (automatic project disabling enabled)
# AUTO_PROJECT_DISABLE=true
+22
View File
@@ -0,0 +1,22 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['node_modules/**', 'dist/**', '.turbo/**'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
);
+57
View File
@@ -0,0 +1,57 @@
{
"name": "api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently -n \"api,worker\" -c \"blue,magenta\" \"yarn dev:server\" \"yarn dev:worker\"",
"dev:server": "tsx watch src/app.ts",
"dev:worker": "tsx watch src/jobs/worker.ts",
"build": "tsc",
"start": "node dist/app.js",
"start:worker": "node dist/jobs/worker.js",
"lint": "eslint .",
"clean": "rimraf node_modules dist .turbo"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.937.0",
"@aws-sdk/client-ses": "^3.937.0",
"@overnightjs/core": "^1.7.6",
"@plunk/db": "*",
"@plunk/email": "*",
"@plunk/shared": "*",
"@plunk/types": "*",
"@react-email/render": "^2.0.0",
"@zootools/email-spell-checker": "^1.12.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.63.2",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"dayjs": "^1.11.19",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"helmet": "^8.1.0",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"signale": "^1.4.0",
"stripe": "^20.0.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/signale": "^1.4.7",
"concurrently": "^9.2.1",
"tsx": "^4.20.6"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
import {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:
// 1. Have the API server running or import the app instance
// 2. For now, these tests demonstrate the pattern for integration testing
describe('Campaigns API Integration Tests', () => {
let projectId: string;
let _authToken: string;
let _apiUrl: string;
const prisma = getPrismaClient();
beforeAll(() => {
// In a real setup, you'd either:
// 1. Import the Express app instance
// 2. Or use the actual API server URL
_apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
});
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
// Generate auth token (you'd need to implement this based on your auth system)
// This is a placeholder - adjust based on your actual auth implementation
_authToken = 'test-jwt-token';
});
describe('POST /campaigns', () => {
it('should create a new campaign', async () => {
const campaignData = {
name: 'Test Campaign',
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.ALL,
};
// Example of how the integration test would look
// Uncomment when you have the app instance available:
/*
const response = await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${authToken}`)
.send(campaignData)
.expect(201);
expect(response.body.name).toBe('Test Campaign');
expect(response.body.status).toBe(CampaignStatus.DRAFT);
*/
// For now, just verify the factory can create the data
const campaign = await factories.createCampaign({
projectId,
...campaignData,
});
expect(campaign.name).toBe('Test Campaign');
});
it('should validate required fields', async () => {
// Test that API validates required fields
// This would fail without name, subject, etc.
const _invalidData = {
from: 'test@example.com',
};
// When integrated with supertest:
/*
await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${_authToken}`)
.send(_invalidData)
.expect(400);
*/
});
});
describe('GET /campaigns', () => {
it('should list campaigns with pagination', async () => {
// Create test campaigns using bulk insert to avoid memory issues
const campaignData = Array.from({length: 25}, (_, i) => ({
projectId,
name: `Campaign ${i}`,
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
status: 'DRAFT' as const,
}));
await prisma.campaign.createMany({data: campaignData});
// When integrated:
/*
const response = await request(app)
.get('/campaigns')
.query({ page: 1, pageSize: 10 })
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.campaigns).toHaveLength(10);
expect(response.body.total).toBe(25);
expect(response.body.totalPages).toBe(3);
*/
const campaigns = await prisma.campaign.findMany({
where: {projectId},
take: 10,
});
expect(campaigns.length).toBeLessThanOrEqual(10);
});
it('should filter campaigns by status', async () => {
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.COMPLETED});
// When integrated:
/*
const response = await request(app)
.get('/campaigns')
.query({ status: CampaignStatus.DRAFT })
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
*/
const draftCampaigns = await prisma.campaign.findMany({
where: {projectId, status: CampaignStatus.DRAFT},
});
expect(draftCampaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
describe('PUT /campaigns/:id', () => {
it('should update a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
name: 'Original Name',
});
// When integrated:
/*
const response = await request(app)
.put(`/campaigns/${campaign.id}`)
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Updated Name' })
.expect(200);
expect(response.body.name).toBe('Updated Name');
*/
const updated = await prisma.campaign.update({
where: {id: campaign.id},
data: {name: 'Updated Name'},
});
expect(updated.name).toBe('Updated Name');
});
it('should not update a completed campaign', async () => {
const _campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.COMPLETED,
});
// When integrated:
/*
await request(app)
.put(`/campaigns/${_campaign.id}`)
.set('Authorization', `Bearer ${_authToken}`)
.send({ name: 'New Name' })
.expect(400);
*/
});
});
describe('DELETE /campaigns/:id', () => {
it('should delete a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
// When integrated:
/*
await request(app)
.delete(`/campaigns/${campaign.id}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(204);
const deleted = await prisma.campaign.findUnique({ where: { id: campaign.id } });
expect(deleted).toBeNull();
*/
await prisma.campaign.delete({where: {id: campaign.id}});
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
expect(deleted).toBeNull();
});
});
});
@@ -0,0 +1,458 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../../services/DomainService.js';
import * as SESService from '../../services/SESService.js';
/**
* Integration tests for Domain verification and ownership checks
* Tests the security feature that prevents domains from being linked to multiple projects
* unless the user is a member of the project that owns the domain
*/
describe('Domain Verification and Ownership Tests', () => {
const prisma = getPrismaClient();
// Mock SES service to avoid external AWS calls
beforeEach(() => {
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
status: 'Success',
tokens: ['token1', 'token2', 'token3'],
});
});
// ========================================
// DOMAIN OWNERSHIP CHECKS
// ========================================
describe('Domain Ownership Verification', () => {
it('should allow adding a domain that does not exist yet', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'new-domain.com';
const ownershipCheck = await DomainService.checkDomainOwnership(domain, 'any-user-id');
expect(ownershipCheck.exists).toBe(false);
// Should be able to add the domain
const newDomain = await DomainService.addDomain(project.id, domain);
expect(newDomain.domain).toBe(domain);
expect(newDomain.projectId).toBe(project.id);
});
it('should detect when a domain already exists', async () => {
const {user, project} = await factories.createUserWithProject();
const domain = 'existing-domain.com';
// First project adds the domain
await DomainService.addDomain(project.id, domain);
// Check ownership - user IS a member of the project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.projectId).toBe(project.id);
expect(ownershipCheck.projectName).toBe(project.name);
expect(ownershipCheck.isMember).toBe(true);
});
it('should detect when a domain exists but user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
const domain = 'other-project-domain.com';
// Project 1 adds the domain
await DomainService.addDomain(project1.id, domain);
// Check ownership for User 2 (not a member of Project 1)
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
expect(ownershipCheck.isMember).toBe(false);
});
it('should allow access when user is a member of the project that owns the domain', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'shared-domain.com';
// User 1 adds the domain to their project
await DomainService.addDomain(project.id, domain);
// Create another user and add them to the same project
const user2 = await factories.createUser({email: 'user2@test.com'});
await prisma.membership.create({
data: {
userId: user2.id,
projectId: project.id,
role: 'ADMIN',
},
});
// Check ownership for User 2 (who is now a member)
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
});
});
// ========================================
// PREVENTING UNAUTHORIZED DOMAIN LINKING
// ========================================
describe('Preventing Unauthorized Domain Linking', () => {
it('should prevent linking a domain to another project when user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
const domain = 'protected-domain.com';
// Project 1 adds the domain
await DomainService.addDomain(project1.id, domain);
// User 2 tries to link the same domain to Project 2
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
// The controller would use this check to deny access
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(false);
// Verify the domain is still only linked to Project 1
const domains = await prisma.domain.findMany({
where: {domain},
});
expect(domains).toHaveLength(1);
expect(domains[0].projectId).toBe(project1.id);
});
it('should allow member to see they can access domain from the original project', async () => {
const {user, project: project1} = await factories.createUserWithProject();
const domain = 'member-domain.com';
// Add domain to project 1
await DomainService.addDomain(project1.id, domain);
// User creates a second project (same user, different project)
const project2 = await factories.createProject();
await prisma.membership.create({
data: {
userId: user.id,
projectId: project2.id,
role: 'OWNER',
},
});
// User tries to link the domain to project 2
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
// Should indicate that domain exists and user IS a member
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
expect(ownershipCheck.projectName).toBe(project1.name);
});
});
// ========================================
// DOMAIN VERIFICATION STATUS
// ========================================
describe('Domain Verification Status', () => {
it('should create unverified domain when first added', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'unverified-domain.com';
const newDomain = await DomainService.addDomain(project.id, domain);
expect(newDomain.verified).toBe(false);
expect(newDomain.dkimTokens).toEqual(['token1', 'token2', 'token3']);
});
it('should prevent using unverified domain for sending emails', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'unverified-domain.com';
await DomainService.addDomain(project.id, domain);
// Try to verify email domain
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not verified/i);
});
it('should allow using verified domain for sending emails', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'verified-domain.com';
const newDomain = await DomainService.addDomain(project.id, domain);
// Manually mark as verified (simulating DNS verification)
await prisma.domain.update({
where: {id: newDomain.id},
data: {verified: true},
});
// Should not throw error
const verifiedDomain = await DomainService.verifyEmailDomain(`sender@${domain}`, project.id);
expect(verifiedDomain.verified).toBe(true);
expect(verifiedDomain.domain).toBe(domain);
});
it('should prevent using domain from different project', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const domain = 'project1-domain.com';
const newDomain = await DomainService.addDomain(project1.id, domain);
// Mark as verified
await prisma.domain.update({
where: {id: newDomain.id},
data: {verified: true},
});
// Try to use from different project
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project2.id)).rejects.toThrow(
/belongs to a different project/i,
);
});
it('should prevent using unregistered domain', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'not-registered.com';
// Try to use domain that was never added
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not registered/i);
});
});
// ========================================
// MULTIPLE USERS AND PROJECTS
// ========================================
describe('Multiple Users and Projects Scenarios', () => {
it('should handle 3 users: owner, member, and non-member', async () => {
const {user: owner, project} = await factories.createUserWithProject();
const member = await factories.createUser({email: 'member@test.com'});
const nonMember = await factories.createUser({email: 'nonmember@test.com'});
const domain = 'team-domain.com';
// Owner adds domain
await DomainService.addDomain(project.id, domain);
// Add member to project
await prisma.membership.create({
data: {
userId: member.id,
projectId: project.id,
role: 'MEMBER',
},
});
// Check ownership for each user
const ownerCheck = await DomainService.checkDomainOwnership(domain, owner.id);
const memberCheck = await DomainService.checkDomainOwnership(domain, member.id);
const nonMemberCheck = await DomainService.checkDomainOwnership(domain, nonMember.id);
expect(ownerCheck.isMember).toBe(true);
expect(memberCheck.isMember).toBe(true);
expect(nonMemberCheck.isMember).toBe(false);
});
it('should handle user with multiple projects', async () => {
const {user, project: project1} = await factories.createUserWithProject();
const project2 = await factories.createProject();
const domain = 'multi-project-domain.com';
// Add user to second project
await prisma.membership.create({
data: {
userId: user.id,
projectId: project2.id,
role: 'ADMIN',
},
});
// Add domain to project 1
await DomainService.addDomain(project1.id, domain);
// User is member of both projects, but domain belongs to project 1
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
expect(ownershipCheck.exists).toBe(true);
expect(ownershipCheck.isMember).toBe(true);
expect(ownershipCheck.projectId).toBe(project1.id);
});
it('should handle domain being removed and re-added', async () => {
const {user, project} = await factories.createUserWithProject();
const domain = 'reusable-domain.com';
// Add domain
const domain1 = await DomainService.addDomain(project.id, domain);
// Remove domain
await DomainService.removeDomain(domain1.id);
// Check ownership - should not exist
const checkAfterRemoval = await DomainService.checkDomainOwnership(domain, user.id);
expect(checkAfterRemoval.exists).toBe(false);
// Re-add domain
const domain2 = await DomainService.addDomain(project.id, domain);
expect(domain2.domain).toBe(domain);
expect(domain2.projectId).toBe(project.id);
});
});
// ========================================
// ROLE-BASED RESTRICTIONS
// ========================================
describe('Role-Based Domain Access', () => {
it('should verify that only ADMIN and OWNER can add domains', async () => {
const {project} = await factories.createUserWithProject();
const regularMember = await factories.createUser({email: 'member@test.com'});
await prisma.membership.create({
data: {
userId: regularMember.id,
projectId: project.id,
role: 'MEMBER',
},
});
// The controller checks for role: { in: ['ADMIN', 'OWNER'] }
// This test verifies the database structure supports this check
const membership = await prisma.membership.findFirst({
where: {
userId: regularMember.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
expect(membership).toBeNull();
});
it('should verify that ADMIN and OWNER roles can add domains', async () => {
const {user: owner, project} = await factories.createUserWithProject();
const admin = await factories.createUser({email: 'admin@test.com'});
await prisma.membership.create({
data: {
userId: admin.id,
projectId: project.id,
role: 'ADMIN',
},
});
// Verify both owner and admin have required roles
const ownerMembership = await prisma.membership.findFirst({
where: {
userId: owner.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
const adminMembership = await prisma.membership.findFirst({
where: {
userId: admin.id,
projectId: project.id,
role: {
in: ['ADMIN', 'OWNER'],
},
},
});
expect(ownerMembership).not.toBeNull();
expect(adminMembership).not.toBeNull();
});
});
// ========================================
// EDGE CASES
// ========================================
describe('Edge Cases', () => {
it('should handle case-sensitive domain names', async () => {
const {project} = await factories.createUserWithProject();
// Domains are typically case-insensitive in DNS, but stored as-is in DB
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
expect(domain1.domain).toBe('Example.com');
});
it('should handle subdomain vs root domain', async () => {
const {project} = await factories.createUserWithProject();
const rootDomain = await DomainService.addDomain(project.id, 'example.com');
const subDomain = await DomainService.addDomain(project.id, 'mail.example.com');
expect(rootDomain.domain).toBe('example.com');
expect(subDomain.domain).toBe('mail.example.com');
// Both should be separate entries
const domains = await prisma.domain.findMany({
where: {projectId: project.id},
});
expect(domains).toHaveLength(2);
});
it('should handle invalid email format in verifyEmailDomain', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('not-an-email', project.id)).rejects.toThrow(
/invalid email format/i,
);
await expect(DomainService.verifyEmailDomain('multiple@at@signs.com', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
});
// ========================================
// GET PROJECT DOMAINS
// ========================================
describe('Get Project Domains', () => {
it('should return all domains for a project', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'domain1.com');
await DomainService.addDomain(project.id, 'domain2.com');
await DomainService.addDomain(project.id, 'domain3.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(3);
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com', 'domain3.com']);
});
it('should return empty array for project with no domains', async () => {
const {project} = await factories.createUserWithProject();
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(0);
});
it('should not return domains from other projects', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
await DomainService.addDomain(project1.id, 'project1-domain.com');
await DomainService.addDomain(project2.id, 'project2-domain.com');
const project1Domains = await DomainService.getProjectDomains(project1.id);
const project2Domains = await DomainService.getProjectDomains(project2.id);
expect(project1Domains).toHaveLength(1);
expect(project1Domains[0].domain).toBe('project1-domain.com');
expect(project2Domains).toHaveLength(1);
expect(project2Domains[0].domain).toBe('project2-domain.com');
});
});
});
+500
View File
@@ -0,0 +1,500 @@
import {Server} from '@overnightjs/core';
import cookies from 'cookie-parser';
import cors from 'cors';
import type {NextFunction, Request, Response} from 'express';
import {json, raw} from 'express';
import helmet from 'helmet';
import morgan from 'morgan';
import signale from 'signale';
import {ZodError} from 'zod';
import {
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
PLUNK_ENABLED,
PORT,
S3_ENABLED,
SMTP_ENABLED,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI,
} from './app/constants.js';
import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js';
import {Analytics} from './controllers/Analytics.js';
import {Auth} from './controllers/Auth.js';
import {Campaigns} from './controllers/Campaigns.js';
import {Contacts} from './controllers/Contacts.js';
import {Domains} from './controllers/Domains.js';
import {Events} from './controllers/Events.js';
import {Oauth} from './controllers/Oauth/index.js';
import {Projects} from './controllers/Projects.js';
import {Segments} from './controllers/Segments.js';
import {Templates} from './controllers/Templates.js';
import {Uploads} from './controllers/Uploads.js';
import {Users} from './controllers/Users.js';
import {Webhooks} from './controllers/Webhooks.js';
import {Workflows} from './controllers/Workflows.js';
import {Config} from './controllers/Config.js';
import {prisma} from './database/prisma.js';
import {ErrorCode, type FieldError, HttpException, ValidationError} from './exceptions/index.js';
import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js';
import * as S3Service from './services/S3Service.js';
import {requestIdMiddleware} from './middleware/requestId.js';
import {databaseRequestLogger} from './middleware/requestLogger.js';
import {logger, requestLogger} from './utils/logger.js';
const server = new (class extends Server {
public constructor() {
super();
// Specify that we need raw json for the webhook
this.app.use('/webhooks/incoming/stripe', raw({type: 'application/json'}));
// Set the content-type to JSON for any request coming from AWS SNS
this.app.use(function (req, res, next) {
if (req.get('x-amz-sns-message-type')) {
req.headers['content-type'] = 'application/json';
}
next();
});
// Parse the rest of our application as json
this.app.use(json({limit: '50mb'}));
this.app.use(cookies());
this.app.use(helmet());
// Add request ID to all requests for tracking
this.app.use(requestIdMiddleware);
// Log all requests and responses with request ID (console/file logs)
this.app.use(requestLogger);
// Log all requests to database for historical tracking and analytics
this.app.use(databaseRequestLogger);
// 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];
// 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: (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'));
this.addControllers([
new Actions(),
new Activity(),
new Analytics(),
new Auth(),
new Campaigns(),
new Oauth(),
new Users(),
new Contacts(),
new Domains(),
new Projects(),
new Segments(),
new Templates(),
new Uploads(),
new Webhooks(),
new Workflows(),
new Events(),
new Config(),
]);
this.app.get('/health', (req, res) => {
res.status(200).json({
status: 'ok',
time: Date.now(),
environment: NODE_ENV,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
});
});
this.app.get('/', (_, res) => res.redirect(LANDING_URI));
this.app.use('*', () => {
throw new HttpException(404, 'Unknown route');
});
}
})();
/**
* Standardized error response format
*/
interface ErrorResponse {
success: false;
error: {
code: string; // Machine-readable error code (e.g., "VALIDATION_ERROR")
message: string; // Human-readable error message
statusCode: number; // HTTP status code
requestId?: string; // Request ID for tracking
errors?: FieldError[]; // Field-level validation errors
details?: Record<string, unknown>; // Additional error context
suggestion?: string; // Helpful suggestion for fixing the error
};
timestamp: string; // ISO timestamp
}
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
const requestId = res.locals.requestId as string | undefined;
// Handle 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 => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
received: err.code !== 'invalid_type' ? undefined : 'received' in err ? err.received : undefined,
}));
const statusCode = 422;
// Create helpful suggestions based on common validation errors
let suggestion = 'Please check the API documentation for the correct request format.';
if (fieldErrors.some(e => e.code === 'invalid_type')) {
suggestion =
'One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false.';
} else if (fieldErrors.some(e => e.message.includes('required'))) {
suggestion = 'Required fields are missing. Ensure all required fields are included in your request.';
}
// Log validation errors with structured logging
logger.warn(
'Validation failed',
{
endpoint: `${req.method} ${req.path}`,
fieldCount: fieldErrors.length,
fields: fieldErrors.map(e => e.field),
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.VALIDATION_ERROR,
message: 'Request validation failed',
statusCode,
requestId,
errors: fieldErrors,
suggestion,
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle custom HTTP exceptions
if (error instanceof HttpException) {
const statusCode = error.code;
const errorCode = error.errorCode || ErrorCode.INTERNAL_SERVER_ERROR;
// Extract validation errors if this is a ValidationError
const validationError = error instanceof ValidationError ? error : null;
// Log based on severity with structured logging
if (statusCode >= 500) {
logger.error(
error.message,
error,
{
endpoint: `${req.method} ${req.path}`,
errorCode,
statusCode,
},
res,
);
} else if (statusCode >= 400) {
logger.warn(
error.message,
{
endpoint: `${req.method} ${req.path}`,
errorCode,
statusCode,
},
res,
);
}
// Generate helpful suggestions based on error type
let suggestion: string | undefined;
switch (errorCode) {
case ErrorCode.INVALID_API_KEY:
suggestion = 'Verify your API key is correct and starts with "sk_" for secret keys or "pk_" for public keys.';
break;
case ErrorCode.MISSING_AUTH:
suggestion = 'Include an Authorization header with format: "Authorization: Bearer YOUR_API_KEY"';
break;
case ErrorCode.FORBIDDEN:
suggestion =
'This action requires additional permissions. Check that you have access to this resource or contact support.';
break;
case ErrorCode.TEMPLATE_NOT_FOUND:
suggestion =
'Ensure the template ID is correct and belongs to your project. You can list available templates via the API.';
break;
case ErrorCode.VALIDATION_ERROR:
suggestion = 'Check the "errors" array for specific field-level validation issues.';
break;
case ErrorCode.RATE_LIMIT_EXCEEDED:
suggestion = 'You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan.';
break;
case ErrorCode.UPGRADE_REQUIRED:
suggestion = 'This feature requires a higher plan. Visit your dashboard to upgrade.';
break;
case ErrorCode.PROJECT_DISABLED:
suggestion = 'Your project has been disabled. Contact support for assistance.';
break;
}
const response: ErrorResponse = {
success: false,
error: {
code: errorCode,
message: error.message,
statusCode,
requestId,
errors: validationError?.errors,
details: error.details,
suggestion,
},
timestamp: new Date().toISOString(),
};
return res.status(statusCode).json(response);
}
// Handle unexpected errors (not HttpException)
const statusCode = 500;
logger.error(
'Unexpected error occurred',
error,
{
endpoint: `${req.method} ${req.path}`,
},
res,
);
const response: ErrorResponse = {
success: false,
error: {
code: ErrorCode.INTERNAL_SERVER_ERROR,
message: 'An unexpected error occurred',
statusCode,
requestId,
suggestion: 'This is an internal server error. Please contact support with the request ID if the issue persists.',
},
timestamp: new Date().toISOString(),
};
res.status(statusCode).json(response);
});
// Global error handlers to prevent server crashes
process.on('unhandledRejection', (reason, promise) => {
signale.error('Unhandled Promise Rejection:', reason);
signale.error('Promise:', promise);
// Don't exit the process - just log the error
});
process.on('uncaughtException', error => {
signale.error('Uncaught Exception:', error);
// Don't exit the process - just log the error
});
void prisma.$connect().then(async () => {
server.app.listen(PORT, '0.0.0.0', () => signale.success('[HTTPS] Ready on', PORT));
// Feature matrix
const features = [
{
name: 'Billing (Stripe)',
enabled: STRIPE_ENABLED,
details: STRIPE_ENABLED ? 'Stripe billing enabled' : 'No Stripe keys configured',
},
{
name: 'File uploads (S3)',
enabled: S3_ENABLED,
details: S3_ENABLED ? 'S3 storage enabled' : 'S3 credentials missing',
},
{name: 'SMTP relay', enabled: SMTP_ENABLED, details: SMTP_ENABLED ? 'SMTP server enabled' : 'SMTP disabled'},
{
name: 'OAuth - GitHub',
enabled: GITHUB_OAUTH_ENABLED,
details: GITHUB_OAUTH_ENABLED ? 'GitHub login enabled' : 'GitHub OAuth not configured',
},
{
name: 'OAuth - Google',
enabled: GOOGLE_OAUTH_ENABLED,
details: GOOGLE_OAUTH_ENABLED ? 'Google login enabled' : 'Google OAuth not configured',
},
{
name: 'Tracking toggle',
enabled: TRACKING_TOGGLE_ENABLED,
details: TRACKING_TOGGLE_ENABLED
? 'Per-project tracking toggle enabled'
: 'Always tracking or always no-tracking',
},
{
name: 'Platform emails',
enabled: PLUNK_ENABLED,
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
},
];
const rows = features.map(f => ({
Feature: f.name,
Enabled: f.enabled ? '✅' : '❌',
}));
console.table(rows);
if (S3_ENABLED) {
try {
await S3Service.initializeBucket();
} catch (error) {
signale.error('[S3] Failed to initialize bucket:', error);
signale.warn('[S3] File uploads may not work properly');
}
}
// Set up repeatable job for domain verification (BullMQ)
// Run every 5 minutes to check domain verification status with AWS SES
await domainVerificationQueue.add(
'check-domain-verification',
{},
{
repeat: {
pattern: '*/5 * * * *', // Every 5 minutes
},
jobId: 'domain-verification-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] Domain verification scheduled (BullMQ repeatable job, runs every 5 minutes)');
// Set up repeatable job for segment count updates (BullMQ)
// Run every 5 minutes to compute membership changes and trigger events
await segmentCountQueue.add(
'update-segment-counts',
{},
{
repeat: {
pattern: '*/5 * * * *', // Every 5 minutes
},
jobId: 'segment-count-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] Segment count updater scheduled (BullMQ repeatable job, runs every 5 minutes)');
// Set up repeatable job for API request log cleanup (BullMQ)
// Run daily at 3 AM to clean up old request logs
await apiRequestCleanupQueue.add(
'cleanup-old-requests',
{},
{
repeat: {
pattern: '0 3 * * *', // Daily at 3 AM
},
jobId: 'api-request-cleanup-repeatable', // Fixed ID to prevent duplicates
},
);
signale.info('[BACKGROUND-JOB] API request cleanup scheduled (BullMQ repeatable job, runs daily at 3 AM)');
});
+117
View File
@@ -0,0 +1,117 @@
import dotenv from 'dotenv';
dotenv.config({quiet: true});
/**
* Safely parse environment variables
* @param key The key
* @param defaultValue An optional default value if the environment variable does not exist
*/
export function validateEnv<T extends string = string>(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
const value = process.env[key] as T | undefined;
if (!value) {
if (typeof defaultValue !== 'undefined') {
return defaultValue;
} else {
throw new Error(`${key} is not defined in environment variables`);
}
}
return value;
}
// Environment
export const NODE_ENV = validateEnv('NODE_ENV', 'development');
export const JWT_SECRET = validateEnv('JWT_SECRET');
export const PORT = Number(validateEnv('PORT', '8080'));
// URLs
export const API_URI = validateEnv('API_URI');
export const DASHBOARD_URI = validateEnv('DASHBOARD_URI');
export const LANDING_URI = validateEnv('LANDING_URI');
export const WIKI_URI = validateEnv('WIKI_URI');
// S3-compatible storage (Minio)
export const S3_ENDPOINT = validateEnv('S3_ENDPOINT', 'http://minio:9000');
export const S3_ACCESS_KEY_ID = validateEnv('S3_ACCESS_KEY_ID', '');
export const S3_ACCESS_KEY_SECRET = validateEnv('S3_ACCESS_KEY_SECRET', '');
export const S3_BUCKET = validateEnv('S3_BUCKET', 'uploads');
export const S3_PUBLIC_URL = validateEnv('S3_PUBLIC_URL', '');
export const S3_FORCE_PATH_STYLE = validateEnv('S3_FORCE_PATH_STYLE', 'true') === 'true';
export const S3_ENABLED = S3_ACCESS_KEY_ID !== '' && S3_ACCESS_KEY_SECRET !== '';
// AWS SES (required for email sending)
export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
// Email Processing Rate Limit (optional override)
// If not set, will automatically fetch from AWS SES account quota
// Set this to override AWS quota (useful for setting lower limits or testing)
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
: undefined;
// Storage
export const REDIS_URL = validateEnv('REDIS_URL');
export const DATABASE_URL = validateEnv('DATABASE_URL');
export const DIRECT_DATABASE_URL = validateEnv('DIRECT_DATABASE_URL');
// OAuth (optional - for social login)
export const GITHUB_OAUTH_CLIENT = validateEnv('GITHUB_OAUTH_CLIENT', '');
export const GITHUB_OAUTH_SECRET = validateEnv('GITHUB_OAUTH_SECRET', '');
export const GITHUB_OAUTH_ENABLED = GITHUB_OAUTH_CLIENT !== '' && GITHUB_OAUTH_SECRET !== '';
export const GOOGLE_OAUTH_CLIENT = validateEnv('GOOGLE_OAUTH_CLIENT', '');
export const GOOGLE_OAUTH_SECRET = validateEnv('GOOGLE_OAUTH_SECRET', '');
export const GOOGLE_OAUTH_ENABLED = GOOGLE_OAUTH_CLIENT !== '' && GOOGLE_OAUTH_SECRET !== '';
// Stripe (optional - if not set, billing features are disabled)
export const STRIPE_SK = validateEnv('STRIPE_SK', '');
export const STRIPE_WEBHOOK_SECRET = validateEnv('STRIPE_WEBHOOK_SECRET', '');
export const STRIPE_ENABLED = STRIPE_SK !== '' && STRIPE_WEBHOOK_SECRET !== '';
// Stripe Pricing Configuration
export const STRIPE_PRICE_ONBOARDING = validateEnv('STRIPE_PRICE_ONBOARDING', ''); // One-time onboarding fee
export const STRIPE_PRICE_EMAIL_USAGE = validateEnv('STRIPE_PRICE_EMAIL_USAGE', ''); // Metered usage price for pay-per-email
export const STRIPE_METER_EVENT_NAME = validateEnv('STRIPE_METER_EVENT_NAME', 'emails'); // Meter event name (API key in Stripe)
// Email Tracking
export const SES_CONFIGURATION_SET = validateEnv('SES_CONFIGURATION_SET', 'plunk-configuration-set');
export const SES_CONFIGURATION_SET_NO_TRACKING = validateEnv(
'SES_CONFIGURATION_SET_NO_TRACKING',
'plunk-configuration-set-no-tracking',
);
// Check if no-tracking configuration set was explicitly provided (not using default)
export const TRACKING_TOGGLE_ENABLED = process.env.SES_CONFIGURATION_SET_NO_TRACKING !== undefined;
// SMTP Server Configuration (optional)
// SMTP server can run with or without a domain (runs without TLS in dev mode)
// Check if we should enable SMTP features in the UI
export const SMTP_DOMAIN = validateEnv('SMTP_DOMAIN', 'localhost');
export const SMTP_PORT_SECURE = Number(validateEnv('PORT_SECURE', '465'));
export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587'));
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
export const SMTP_ENABLED =
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
// Security (optional)
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
// Useful for self-hosters who want to manage project status manually
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
// 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
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
+14
View File
@@ -0,0 +1,14 @@
import Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_SK} from './constants.js';
/**
* Stripe client instance
* Only initialized if STRIPE_SK is configured
* Returns null if billing is disabled (self-hosted mode)
*/
export const stripe = STRIPE_ENABLED
? new Stripe(STRIPE_SK, {
apiVersion: '2025-11-17.clover',
})
: null;
+403
View File
@@ -0,0 +1,403 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import {ActionSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {DomainService} from '../services/DomainService.js';
import {EmailService} from '../services/EmailService.js';
import {EmailVerificationService} from '../services/EmailVerificationService.js';
import {EventService} from '../services/EventService.js';
import {NotFound, ValidationError} from '../exceptions/index.js';
import {CatchAsync} from '../utils/asyncHandler.js';
import {DASHBOARD_URI} from '../app/constants.js';
/**
* Public API Actions Controller
* Handles track event, transactional email, and email verification endpoints
*/
@Controller('v1')
export class Actions {
/**
* POST /v1/track
* Track an event for a contact (creates/updates contact and tracks event)
*
* Request body:
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
*
* Response:
* - success: boolean
* - data: object with contact ID, event ID, and timestamp
*
* Example:
* {
* event: "purchase",
* email: "user@example.com",
* data: {
* totalSpent: 1500, // Persistent - saved to contact
* plan: "pro", // Persistent - saved to contact
* orderId: {value: "12345", persistent: false}, // Non-persistent - workflows only
* receiptUrl: {value: "https://...", persistent: false} // Non-persistent - workflows only
* }
* }
*/
@Post('track')
@Middleware([requirePublicKey])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
// Prevent manual tracking of reserved system events
if (EventService.isReservedEvent(event)) {
throw new ValidationError(
[
{
field: 'event',
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
code: 'reserved_event',
received: event,
},
],
'Cannot track reserved system event',
);
}
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
// 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,
data as Record<string, unknown> | undefined,
subscribed,
);
// Track the event with ALL data (persistent + non-persistent)
// Non-persistent data flows to workflows via execution context
const eventRecord = await EventService.trackEvent(
auth.projectId,
event,
contact.id,
undefined,
data as Record<string, unknown> | undefined,
);
return res.status(200).json({
success: true,
data: {
contact: contact.id,
event: eventRecord.id,
timestamp: eventRecord.createdAt.toISOString(),
},
});
}
/**
* POST /v1/send
* Send transactional email(s)
*
* Request body:
* - to: string | object | array (required) - Recipient email(s)
* - String: "user@example.com"
* - Object: {name: "Jane Doe", email: "user@example.com"}
* - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
* - subject: string (required) - Email subject
* - body: string (required) - Email HTML body
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
* - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
* - filename: string (required) - Attachment filename
* - content: string (required) - Base64 encoded file content
* - contentType: string (required) - MIME type (e.g., "application/pdf")
*
* Response:
* - success: boolean
* - data: object with emails array and timestamp
*
* Examples:
*
* Simple format (backward compatible):
* {
* to: "user@example.com",
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
* from: "noreply@example.com",
* name: "My App",
* data: {
* firstName: "John", // Persistent - saved to contact
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
* }
* }
*
* Object format (recommended):
* {
* to: {
* name: "Jane Doe",
* email: "user@example.com"
* },
* subject: "Password Reset",
* body: "<p>Reset code: {{resetCode}}</p>",
* from: {
* name: "My App",
* email: "noreply@example.com"
* }
* }
*
* Multiple recipients with names:
* {
* to: [
* {name: "Jane Doe", email: "jane@example.com"},
* {name: "John Smith", email: "john@example.com"}
* ],
* subject: "Newsletter",
* body: "<p>Hello {{name}}!</p>",
* from: {name: "Newsletter", email: "news@example.com"}
* }
*/
@Post('send')
@Middleware([requireSecretKey])
@CatchAsync
public async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body);
// Normalize recipients to array and parse email/name
type Recipient = {email: string; name?: string};
const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => {
if (typeof recipient === 'string') {
return {email: recipient};
} else {
return {email: recipient.email, name: recipient.name};
}
});
// Parse 'from' field - can be string or object {name, email}
let emailFrom: string | undefined;
let emailFromName: string | undefined;
if (typeof from === 'string') {
// Backward compatible: from is just an email string
emailFrom = from;
emailFromName = name; // Use separate 'name' field if provided
} else if (from && typeof from === 'object') {
// New format: from is an object with {name, email}
emailFrom = from.email;
emailFromName = from.name || name; // Prefer from.name, fallback to separate 'name' field
} else {
// No 'from' provided
emailFromName = name;
}
// Fetch template if provided
let emailSubject = subject;
let emailBody = body;
let emailReplyTo = reply;
let templateId: string | undefined;
if (template) {
const templateRecord = await prisma.template.findUnique({
where: {
id: template,
projectId: auth.projectId, // Ensure template belongs to this project
},
});
if (!templateRecord) {
throw new NotFound('Template', template);
}
// Use template values, allow overrides from request
emailSubject = subject || templateRecord.subject;
emailBody = body || templateRecord.body;
// Handle from field - if not already set and template has a from, use it
if (!emailFrom && templateRecord.from) {
emailFrom = templateRecord.from;
}
if (!emailFromName && templateRecord.fromName) {
emailFromName = templateRecord.fromName;
}
emailReplyTo = reply || templateRecord.replyTo || undefined;
templateId = templateRecord.id;
}
if (!emailFrom) {
throw new ValidationError(
[
{
field: 'from',
message: 'Sender email is required either in request or template',
code: 'required',
},
],
'Could not parse sender email',
);
}
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
const replyToEmail = emailReplyTo;
const timestamp = new Date();
const emailResults = [];
// Process each recipient
for (const recipient of recipients) {
// Merge recipient name with data if provided
const recipientData = recipient.name
? {...(data as Record<string, unknown> | undefined), name: recipient.name}
: (data as Record<string, unknown> | undefined);
// Create or update contact with metadata
// 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);
// Add system variables (email, unsubscribe URLs, etc.) to merged data
// These are always available for template rendering
const dataWithSystemVars = {
...mergedData,
id: contact.id,
email: contact.email,
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
// Render template with contact data
// Simple template variable replacement: {{fieldname}}
let renderedSubject = emailSubject!;
let renderedBody = emailBody!;
for (const [key, value] of Object.entries(dataWithSystemVars)) {
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
// Replace with value
const stringValue = value !== null && value !== undefined ? String(value) : '';
renderedSubject = renderedSubject!.replace(placeholder, stringValue);
renderedBody = renderedBody!.replace(placeholder, stringValue);
// Handle fallback syntax: {{field ?? default}}
renderedSubject = renderedSubject!.replace(fallbackPlaceholder, stringValue || '$1');
renderedBody = renderedBody!.replace(fallbackPlaceholder, stringValue || '$1');
}
// Replace any remaining placeholders with empty string or fallback value
renderedSubject = renderedSubject!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
renderedBody = renderedBody!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
// Handle fallback placeholders that weren't matched
renderedSubject = renderedSubject!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
renderedBody = renderedBody!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
const email = await EmailService.sendTransactionalEmail({
projectId: auth.projectId,
contactId: contact.id,
subject: renderedSubject,
body: renderedBody,
from: emailFrom,
fromName: emailFromName,
toName: recipient.name,
replyTo: replyToEmail,
headers: headers || undefined,
attachments: attachments || undefined,
templateId: templateId,
});
emailResults.push({
contact: {
id: contact.id,
email: contact.email,
},
email: email.id,
});
}
return res.status(200).json({
success: true,
data: {
emails: emailResults,
timestamp: timestamp.toISOString(),
},
});
}
/**
* POST /v1/verify
* Verify an email address
*
* Request body:
* - email: string (required) - Email address to verify
*
* Response:
* - success: boolean
* - data: object with verification results
* - email: string - Email address that was verified
* - valid: boolean - Whether the email appears to be valid
* - isDisposable: boolean - Whether the email is from a disposable domain
* - hasMxRecords: boolean - Whether the domain has MX records configured
* - suggestedEmail?: string - Suggested correction if typo detected
* - reasons: string[] - Array of reasons describing the verification results
*
* Example:
* {
* email: "user@gmial.com"
* }
*
* Response:
* {
* success: true,
* data: {
* email: "user@gmial.com",
* valid: false,
* isDisposable: false,
* hasMxRecords: false,
* suggestedEmail: "user@gmail.com",
* reasons: [
* "Possible typo detected, did you mean user@gmail.com?",
* "Domain does not exist or has no MX records"
* ]
* }
* }
*/
@Post('verify')
@Middleware([requireSecretKey])
@CatchAsync
public async verify(req: Request, res: Response, _next: NextFunction) {
// Zod validation - errors automatically handled by global error handler
const {email} = ActionSchemas.verify.parse(req.body);
// Verify the email address
const verificationResult = await EmailVerificationService.verifyEmail(email);
return res.status(200).json({
success: true,
data: verificationResult,
});
}
}
+127
View File
@@ -0,0 +1,127 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {ActivityType} from '@plunk/types';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ActivityService} from '../services/ActivityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('activity')
export class Activity {
/**
* GET /activity
* Get unified activity feed for the project
*
* Query params:
* - limit: number (default 50, max 100)
* - cursor: string (pagination cursor: timestamp_id)
* - types: ActivityType[] (filter by activity types)
* - contactId: string (filter by contact)
* - startDate: ISO date string
* - endDate: ISO date string
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getActivities(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const cursor = req.query.cursor as string | undefined;
const contactId = req.query.contactId as string | undefined;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
// Parse types filter (comma-separated)
let types: ActivityType[] | undefined;
if (req.query.types) {
const typesParam = req.query.types as string;
types = typesParam
.split(',')
.filter(t => Object.values(ActivityType).includes(t as ActivityType)) as ActivityType[];
}
const result = await ActivityService.getActivities(
auth.projectId,
limit,
cursor,
types,
contactId,
startDate,
endDate,
);
return res.status(200).json(result);
}
/**
* GET /activity/stats
* Get activity statistics for the project
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const stats = await ActivityService.getStats(auth.projectId, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /activity/recent-count
* Get count of recent activities (for real-time updates)
*
* Query params:
* - minutes: number (default 5)
*/
@Get('recent-count')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
return res.status(200).json({count, minutes});
}
/**
* GET /activity/types
* Get available activity types (for UI filters)
*/
@Get('types')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
const types = Object.values(ActivityType);
return res.status(200).json({types});
}
/**
* GET /activity/upcoming
* Get upcoming scheduled activities (campaigns and workflow emails)
*
* Query params:
* - limit: number (default 50, max 100)
* - daysAhead: number (default 30, max 90)
*/
@Get('upcoming')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
const activities = await ActivityService.getUpcomingActivities(auth.projectId, limit, daysAhead);
return res.status(200).json({activities});
}
}
+102
View File
@@ -0,0 +1,102 @@
import {Controller, Get, Middleware} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {AnalyticsService} from '../services/AnalyticsService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('analytics')
export class Analytics {
/**
* GET /analytics/timeseries
* Get time series data for email analytics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago, max 90 days)
* - endDate: ISO date string (defaults to now)
*
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
*/
@Get('timeseries')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const timeSeries = await AnalyticsService.getTimeSeriesData(auth.projectId, startDate, endDate);
return res.status(200).json(timeSeries);
}
/**
* GET /analytics/top-campaigns
* Get top performing campaigns by open rate
*
* Query params:
* - limit: number (default 10)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*/
@Get('top-campaigns')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const topCampaigns = await AnalyticsService.getTopCampaigns(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topCampaigns);
}
/**
* GET /analytics/campaign-stats
* Get campaign overview statistics
*
* Query params:
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns aggregate stats: total campaigns, active, completed, average rates
*/
@Get('campaign-stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const campaignStats = await AnalyticsService.getCampaignStats(auth.projectId, startDate, endDate);
return res.status(200).json(campaignStats);
}
/**
* GET /analytics/top-events
* Get most frequently triggered events
*
* Query params:
* - limit: number (default 5, max 20)
* - startDate: ISO date string (defaults to 30 days ago)
* - endDate: ISO date string (defaults to now)
*
* Returns events sorted by frequency with trend data
*/
@Get('top-events')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const topEvents = await AnalyticsService.getTopEvents(auth.projectId, limit, startDate, endDate);
return res.status(200).json(topEvents);
}
}
+330
View File
@@ -0,0 +1,330 @@
import {Controller, Get, Post} from '@overnightjs/core';
import {AuthenticationSchemas} from '@plunk/shared';
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
import {randomBytes} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
import * as React from 'react';
import {
DASHBOARD_URI,
DISABLE_SIGNUPS,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
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';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('auth')
export class Auth {
@Post('login')
@CatchAsync
public async login(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
const user = await UserService.email(email);
if (!user) {
return res.json({success: false, data: 'Incorrect email or password'});
}
if (user.type === 'PASSWORD' && !user.password) {
return res.json({success: 'redirect', redirect: `/auth/reset?id=${user.id}`});
}
const verified = await AuthService.verifyCredentials(email, password);
if (!verified) {
return res.json({success: false, data: 'Incorrect email or password'});
}
await redis.set(Keys.User.id(user.id), JSON.stringify(user), 'EX', REDIS_ONE_MINUTE * 60);
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
return res
.cookie(UserService.COOKIE_NAME, token, cookie)
.json({success: true, data: {id: user.id, email: user.email}});
}
@Post('signup')
@CatchAsync
public async signup(req: Request, res: Response, _next: NextFunction) {
// 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) {
return res.json({
success: false,
data: 'That email is already associated with another user',
});
}
const created_user = await prisma.user.create({
data: {
email,
password: await AuthService.generateHash(password),
type: 'PASSWORD',
// Auto-verify email if platform emails are disabled
emailVerified: !PLUNK_ENABLED,
},
});
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
// Send notification about new user signup
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
// Send email verification if platform emails are enabled
if (PLUNK_ENABLED) {
const verificationToken = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(verificationToken),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
);
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
await sendPlatformEmail(
created_user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {
email: created_user.email,
verificationUrl,
landingUrl: LANDING_URI,
}),
);
}
const token = jwt.sign(created_user.id);
const cookie = UserService.cookieOptions();
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
success: true,
data: {id: created_user.id, email: created_user.email},
});
}
@Get('logout')
public logout(req: Request, res: Response) {
res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
return res.json(true);
}
@Get('oauth-config')
public oauthConfig(req: Request, res: Response) {
return res.json({
success: true,
data: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
});
}
@Post('verify-email')
@CatchAsync
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
// Look up token in Redis
const data = await redis.get(Keys.User.emailVerificationToken(token));
if (!data) {
throw new BadRequest('Invalid or expired verification token');
}
const {userId} = JSON.parse(data);
// Update user
await prisma.user.update({
where: {id: userId},
data: {emailVerified: true},
});
// Delete token (single use) and invalidate cache
await redis.del(Keys.User.emailVerificationToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Email verified successfully'}});
}
@Post('request-verification')
@CatchAsync
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
const userId = parseJwt(req);
const user = await UserService.id(userId);
if (!user) {
throw new NotAuthenticated();
}
if (user.emailVerified) {
return res.json({success: true, data: {message: 'Email already verified'}});
}
// Check rate limit
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
throw new RateLimitError('Too many verification emails sent. Please try again later.');
}
// Generate token
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.emailVerificationToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
);
// Send email
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
await sendPlatformEmail(
user.email,
'Verify your email address',
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
return res.json({success: true, data: {message: 'Verification email sent'}});
}
@Post('request-password-reset')
@CatchAsync
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
// Check rate limit
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
const count = await redis.get(rateLimitKey);
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
// Still return success to prevent enumeration
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
// Look up user
const user = await UserService.email(email);
// Only send email if user exists and is PASSWORD type
if (user && user.type === 'PASSWORD') {
const token = randomBytes(32).toString('hex');
await redis.setex(
Keys.User.passwordResetToken(token),
TOKEN_EXPIRY_SECONDS,
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
);
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
await sendPlatformEmail(
user.email,
'Reset your password',
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
);
// Increment rate limit
if (count) {
await redis.incr(rateLimitKey);
} else {
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
}
}
// Always return success (prevent enumeration)
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
}
@Post('reset-password')
@CatchAsync
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
// Look up token
const data = await redis.get(Keys.User.passwordResetToken(token));
if (!data) {
throw new BadRequest('Invalid or expired reset token');
}
const {userId} = JSON.parse(data);
// Get user and verify type
const user = await prisma.user.findUnique({where: {id: userId}});
if (!user || user.type !== 'PASSWORD') {
throw new BadRequest('Invalid reset token');
}
// Hash new password and update
const hashedPassword = await AuthService.generateHash(newPassword);
await prisma.user.update({
where: {id: userId},
data: {password: hashedPassword},
});
// Delete token and invalidate cache
await redis.del(Keys.User.passwordResetToken(token));
await redis.del(Keys.User.id(userId));
return res.json({success: true, data: {message: 'Password reset successfully'}});
}
}
+278
View File
@@ -0,0 +1,278 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {HttpException} from '../exceptions/index.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {CampaignService} from '../services/CampaignService.js';
import {DomainService} from '../services/DomainService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('campaigns')
export class Campaigns {
/**
* Create a new campaign
* POST /campaigns
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
if (audienceType === CampaignAudienceType.FILTERED && !audienceCondition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId);
const campaign = await CampaignService.create(auth.projectId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
audienceType,
audienceCondition,
segmentId,
});
return res.status(201).json({
success: true,
data: campaign,
});
}
/**
* Get all campaigns for a project
* GET /campaigns
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const status = req.query.status as CampaignStatus | undefined;
const page = parseInt(req.query.page as string) || 1;
const pageSize = parseInt(req.query.pageSize as string) || 20;
// Validate status if provided
if (status && !Object.values(CampaignStatus).includes(status)) {
throw new HttpException(400, 'Invalid status value');
}
const result = await CampaignService.list(auth.projectId, {
status,
page,
pageSize,
});
return res.json(result);
}
/**
* Get a specific campaign
* GET /campaigns/:id
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.get(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
});
}
/**
* Update a campaign
* PUT /campaigns/:id
*/
@Put(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
req.body;
// Validate audience-specific fields if audienceType is being updated
if (audienceType === CampaignAudienceType.SEGMENT && segmentId === undefined) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
if (audienceType === CampaignAudienceType.FILTERED && audienceCondition === undefined) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId);
}
const campaign = await CampaignService.update(auth.projectId, id!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type: type as TemplateType | undefined,
audienceType,
audienceCondition,
segmentId,
});
return res.json({
success: true,
data: campaign,
});
}
/**
* Delete a campaign
* DELETE /campaigns/:id
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
await CampaignService.delete(auth.projectId, id!);
return res.json({
success: true,
message: 'Campaign deleted successfully',
});
}
/**
* Duplicate a campaign
* POST /campaigns/:id/duplicate
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.duplicate(auth.projectId, id!);
return res.status(201).json({
success: true,
data: campaign,
message: 'Campaign duplicated successfully',
});
}
/**
* Send or schedule a campaign
* POST /campaigns/:id/send
*/
@Post(':id/send')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async send(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const scheduledFor = req.body?.scheduledFor;
// Parse scheduledFor if provided
let scheduledDate: Date | undefined;
if (scheduledFor) {
scheduledDate = new Date(scheduledFor);
if (isNaN(scheduledDate.getTime())) {
throw new HttpException(400, 'Invalid scheduledFor date format');
}
}
const campaign = await CampaignService.send(auth.projectId, id!, scheduledDate);
return res.json({
success: true,
data: campaign,
message: scheduledDate ? `Campaign scheduled for ${scheduledDate.toISOString()}` : 'Campaign is being sent',
});
}
/**
* Cancel a campaign
* POST /campaigns/:id/cancel
*/
@Post(':id/cancel')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async cancel(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const campaign = await CampaignService.cancel(auth.projectId, id!);
return res.json({
success: true,
data: campaign,
message: 'Campaign cancelled successfully',
});
}
/**
* Get campaign statistics
* GET /campaigns/:id/stats
*/
@Get(':id/stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const stats = await CampaignService.getStats(auth.projectId, id!);
return res.json({
success: true,
data: stats,
});
}
/**
* Send a test email for a campaign
* POST /campaigns/:id/test
*/
@Post(':id/test')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async sendTest(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {email} = CampaignSchemas.sendTest.parse(req.body);
await CampaignService.sendTest(auth.projectId, id!, email);
return res.json({
success: true,
message: `Test email sent to ${email}`,
});
}
}
+68
View File
@@ -0,0 +1,68 @@
import {Controller, Get} from '@overnightjs/core';
import type {Request, Response} from 'express';
import {
API_URI,
AWS_SES_REGION,
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI,
NODE_ENV,
S3_ENABLED,
SMTP_DOMAIN,
SMTP_ENABLED,
SMTP_PORT_SECURE,
SMTP_PORT_SUBMISSION,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI,
} from '../app/constants.js';
@Controller('config')
export class Config {
/**
* GET /config
* Expose a unified view of instance capabilities and feature flags for frontends.
*/
@Get('')
public getConfig(req: Request, res: Response) {
return res.status(200).json({
environment: NODE_ENV,
urls: {
api: API_URI,
dashboard: DASHBOARD_URI,
landing: LANDING_URI,
wiki: WIKI_URI || null,
},
features: {
billing: {
enabled: STRIPE_ENABLED,
},
storage: {
s3Enabled: S3_ENABLED,
},
authProviders: {
github: GITHUB_OAUTH_ENABLED,
google: GOOGLE_OAUTH_ENABLED,
},
email: {
trackingToggleEnabled: TRACKING_TOGGLE_ENABLED,
},
smtp: {
enabled: SMTP_ENABLED,
domain: SMTP_ENABLED ? SMTP_DOMAIN : null,
ports: SMTP_ENABLED
? {
secure: SMTP_PORT_SECURE,
submission: SMTP_PORT_SUBMISSION,
}
: null,
},
},
aws: {
sesRegion: AWS_SES_REGION,
},
});
}
}
+528
View File
@@ -0,0 +1,528 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {QueueService} from '../services/QueueService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB max file size
},
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true);
} else {
cb(new Error('Only CSV files are allowed'));
}
},
});
@Controller('contacts')
export class Contacts {
/**
* GET /contacts
* List all contacts for the authenticated project with cursor-based pagination
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const cursor = req.query.cursor as string | undefined;
const search = req.query.search as string | undefined;
const result = await ContactService.list(auth.projectId!, limit, cursor, search);
return res.status(200).json(result);
}
/**
* GET /contacts/fields
* Get all available contact fields (both standard and custom fields from data JSON)
* Returns field names with inferred types (string, number, boolean, date)
*/
@Get('fields')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
try {
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
return res.status(200).json({
fields: fieldsWithTypes,
count: fieldsWithTypes.length,
});
} catch (error) {
signale.error('[CONTACTS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /contacts/fields/:field/values
* Get unique values for a contact field (for workflow conditions, segment filters, etc.)
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
*/
@Get('fields/:field/values')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const field = req.params.field;
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const values = await ContactService.getUniqueFieldValues(auth.projectId!, field, limit);
return res.status(200).json({
field,
values,
count: values.length,
limit,
});
} catch (error) {
signale.error('[CONTACTS] Failed to get field values:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field values',
});
}
}
/**
* GET /contacts/:id
* Get a specific contact by ID
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.get(auth.projectId!, contactId);
return res.status(200).json(contact);
}
/**
* POST /contacts
* Create or update a contact (upsert)
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {email, data, subscribed} = req.body;
if (!email) {
return res.status(400).json({error: 'Email is required'});
}
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(auth.projectId!, email);
const isUpdate = !!existingContact;
const contact = await ContactService.upsert(auth.projectId!, email, data, subscribed);
return res.status(isUpdate ? 200 : 201).json({
...contact,
_meta: {
isNew: !isUpdate,
isUpdate,
},
});
}
/**
* PATCH /contacts/:id
* Update a contact
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const contactId = req.params.id;
const {email, data, subscribed} = req.body;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.update(auth.projectId!, contactId, {email, data, subscribed});
return res.status(200).json(contact);
}
/**
* DELETE /contacts/:id
* Delete a contact
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
await ContactService.delete(auth.projectId!, contactId);
return res.status(204).send();
}
/**
* GET /contacts/public/:id
* PUBLIC: Get contact information (no auth required)
*/
@Get('public/:id')
@CatchAsync
public async getPublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.getById(contactId);
// Fetch project to get language preference
const project = await ContactService.getProjectByContactId(contactId);
// Get contact-level locale (overrides project language)
const contactLocale =
contact.data &&
typeof contact.data === 'object' &&
!Array.isArray(contact.data) &&
'locale' in contact.data &&
typeof contact.data.locale === 'string'
? contact.data.locale
: null;
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
language: contactLocale || project?.language || 'en',
});
}
/**
* POST /contacts/public/:id/subscribe
* PUBLIC: Subscribe a contact (no auth required)
*/
@Post('public/:id/subscribe')
@CatchAsync
public async subscribePublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.subscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/public/:id/unsubscribe
* PUBLIC: Unsubscribe a contact (no auth required)
*/
@Post('public/:id/unsubscribe')
@CatchAsync
public async unsubscribePublic(req: Request, res: Response, _next: NextFunction) {
const contactId = req.params.id;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const contact = await ContactService.unsubscribe(contactId);
return res.status(200).json({
id: contact.id,
email: contact.email,
subscribed: contact.subscribed,
});
}
/**
* POST /contacts/import
* Import contacts from CSV file
*/
@Post('import')
@Middleware([requireAuth, upload.single('file')])
@CatchAsync
public async importCsv(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
if (!req.file) {
return res.status(400).json({error: 'CSV file is required'});
}
try {
// Convert file buffer to base64 for storage in queue
const csvData = req.file.buffer.toString('base64');
const filename = req.file.originalname;
// Queue import job
const job = await QueueService.queueImport(auth.projectId!, csvData, filename);
return res.status(202).json({
message: 'Import queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue import:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue import',
});
}
}
/**
* GET /contacts/import/:jobId
* Get import job status
*/
@Get('import/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
if (!status) {
return res.status(404).json({error: 'Import job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get import status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get import status',
});
}
}
/**
* GET /contacts/fields/:field/usage
* Check if a field is used in segments/campaigns and get usage statistics
* Returns information about where the field is used and whether it can be safely deleted
*/
@Get('fields/:field/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
return res.status(200).json(usage);
} catch (error) {
signale.error('[CONTACTS] Failed to get field usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get field usage',
});
}
}
/**
* DELETE /contacts/fields/:field
* Delete a custom field from all contacts
* Only works if the field is not used in any segments or campaigns
*/
@Delete('fields/:field')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteField(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const field = req.params.field;
if (!field) {
return res.status(400).json({error: 'Field is required'});
}
try {
const result = await ContactService.deleteField(auth.projectId!, field);
return res.status(200).json(result);
} catch (error) {
signale.error('[CONTACTS] Failed to delete field:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete field',
});
}
}
/**
* POST /contacts/bulk-subscribe
* Queue bulk subscribe operation
*/
@Post('bulk-subscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
// Validate limit
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
return res.status(202).json({
message: 'Bulk subscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
});
}
}
/**
* POST /contacts/bulk-unsubscribe
* Queue bulk unsubscribe operation
*/
@Post('bulk-unsubscribe')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
return res.status(202).json({
message: 'Bulk unsubscribe queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
});
}
}
/**
* POST /contacts/bulk-delete
* Queue bulk delete operation
*/
@Post('bulk-delete')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {contactIds} = req.body;
if (!Array.isArray(contactIds) || contactIds.length === 0) {
return res.status(400).json({error: 'contactIds array is required'});
}
if (contactIds.length > 1000) {
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
}
try {
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
return res.status(202).json({
message: 'Bulk delete queued successfully',
jobId: job.id,
});
} catch (error) {
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
});
}
}
/**
* GET /contacts/bulk/:jobId
* Get bulk action job status
*/
@Get('bulk/:jobId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const jobId = req.params.jobId;
if (!jobId) {
return res.status(400).json({error: 'Job ID is required'});
}
try {
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
if (!status) {
return res.status(404).json({error: 'Bulk action job not found'});
}
return res.status(200).json(status);
} catch (error) {
signale.error('[CONTACTS] Failed to get bulk action status:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
});
}
}
}
+135
View File
@@ -0,0 +1,135 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotFound} from '../exceptions/index.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
export class Domains {
/**
* Get all domains for a project
*/
@Get('project/:projectId')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {projectId} = DomainSchemas.projectId.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, projectId);
const domains = await DomainService.getProjectDomains(projectId);
return res.status(200).json(domains);
}
/**
* Add a new domain to a project
*/
@Post('')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async addDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {projectId, domain} = DomainSchemas.create.parse(req.body);
if (!auth.userId) {
throw new NotFound('User authentication required');
}
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
if (ownershipCheck.exists) {
// If domain exists and user is a member of that project, allow it
if (ownershipCheck.isMember) {
return res.status(400).json({
error: `This domain is already linked to project "${ownershipCheck.projectName}". You are a member of that project, so you can use the domain from there.`,
});
}
// Domain exists but user is not a member - deny access
return res.status(403).json({
error: 'This domain is already linked to another project. Only members of that project can use this domain.',
});
}
try {
const newDomain = await DomainService.addDomain(projectId, domain);
await redis.del(Keys.Domain.project(projectId));
return res.status(201).json(newDomain);
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({error: error.message});
}
throw error;
}
}
/**
* Check verification status for a domain
*/
@Get(':id/verify')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// Verify user has access to the project this domain belongs to
await MembershipService.requireAccess(auth.userId!, domain.projectId);
const verificationStatus = await DomainService.checkVerification(id);
// Invalidate cache if status changed
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json(verificationStatus);
}
/**
* Remove a domain from a project
*/
@Delete(':id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const domain = await DomainService.id(id);
if (!domain) {
throw new NotFound('Domain not found');
}
// Verify user has admin access to the project this domain belongs to
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
await DomainService.removeDomain(id);
await redis.del(Keys.Domain.id(id));
await redis.del(Keys.Domain.project(domain.projectId));
return res.status(200).json({success: true});
}
}
+153
View File
@@ -0,0 +1,153 @@
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {EventService} from '../services/EventService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('events')
export class Events {
/**
* POST /events/track
* Track a custom event (can trigger workflows)
*/
@Post('track')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async track(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, contactId, emailId, data} = req.body;
if (!name) {
return res.status(400).json({error: 'Event name is required'});
}
const event = await EventService.trackEvent(auth.projectId!, name, contactId, emailId, data);
return res.status(201).json(event);
}
/**
* GET /events
* List events for the project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
const limit = parseInt(req.query.limit as string) || 100;
const events = await EventService.getProjectEvents(auth.projectId!, eventName, limit);
return res.status(200).json({events});
}
/**
* GET /events/stats
* Get event statistics
*/
@Get('stats')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async stats(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
const stats = await EventService.getEventStats(auth.projectId!, startDate, endDate);
return res.status(200).json(stats);
}
/**
* GET /events/contact/:contactId
* Get events for a specific contact
*/
@Get('contact/:contactId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const contactId = req.params.contactId;
const limit = parseInt(req.query.limit as string) || 50;
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const events = await EventService.getContactEvents(auth.projectId!, contactId, limit);
return res.status(200).json({events});
}
/**
* GET /events/names
* Get unique event names for the project
*/
@Get('names')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
return res.status(200).json({eventNames});
}
/**
* GET /events/:eventName/usage
* Check if an event is used in segments/workflows and get usage statistics
* Returns information about where the event is used and whether it can be safely deleted
*/
@Get(':eventName/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
try {
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
return res.status(200).json(usage);
} catch (error) {
signale.error('[EVENTS] Failed to get event usage:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get event usage',
});
}
}
/**
* DELETE /events/:eventName
* Delete all events with a specific name
* Only works if the event is not used in any segments or workflows
*/
@Delete(':eventName')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const eventName = req.params.eventName;
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
try {
const result = await EventService.deleteEvent(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
signale.error('[EVENTS] Failed to delete event:', error);
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
error: error instanceof Error ? error.message : 'Failed to delete event',
});
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import {Controller, Get} from '@overnightjs/core';
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';
import {CatchAsync} from '../../utils/asyncHandler.js';
@Controller('github')
export class Github {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const OAUTH_QS = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
redirect_uri: `${API_URI}/oauth/github/callback`,
response_type: 'code',
scope: 'user:email',
});
return res.redirect(`https://github.com/login/oauth/authorize?${OAUTH_QS.toString()}`);
}
@Get('callback')
@CatchAsync
public async callback(req: Request, res: Response, _next: NextFunction) {
if (!GITHUB_OAUTH_ENABLED) {
return res.status(404).json({error: 'GitHub OAuth is not configured'});
}
const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({
client_id: GITHUB_OAUTH_CLIENT,
client_secret: GITHUB_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/github/callback`,
});
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
body: data,
}).then(res => res.json());
if (!tokenResponse.access_token || !tokenResponse.token_type) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
}
const emails = await fetch(`https://api.github.com/user/emails`, {
headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
}).then(res => res.json());
if (!Array.isArray(emails) || emails.length === 0) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
}
const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
}
const email = primaryEmail.email;
let user = await UserService.email(email);
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
type: 'GITHUB_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
}
if (user.type !== 'GITHUB_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'GitHub');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+105
View File
@@ -0,0 +1,105 @@
import {Controller, Get} from '@overnightjs/core';
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';
import {CatchAsync} from '../../utils/asyncHandler.js';
@Controller('google')
export class Google {
@Get('outbound')
public sendToOutbound(req: Request, res: Response) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
return res.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.email&access_type=offline&include_granted_scopes=true&prompt=select_account&response_type=code&redirect_uri=${API_URI}/oauth/google/callback&client_id=${GOOGLE_OAUTH_CLIENT}`,
);
}
@Get('callback')
@CatchAsync
public async callback(req: Request, res: Response, _next: NextFunction) {
if (!GOOGLE_OAUTH_ENABLED) {
return res.status(404).json({error: 'Google OAuth is not configured'});
}
const {code} = req.query;
if (!code || typeof code !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
}
const data = new URLSearchParams({
client_id: GOOGLE_OAUTH_CLIENT,
client_secret: GOOGLE_OAUTH_SECRET,
code: code as string,
redirect_uri: `${API_URI}/oauth/google/callback`,
grant_type: 'authorization_code',
});
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {'Content-type': 'application/x-www-form-urlencoded'},
body: data,
}).then(res => res.json());
if (!tokenResponse.access_token) {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
}
const userInfoResponse = await fetch(
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
).then(res => res.json());
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
}
const email = userInfoResponse.email;
let user = await UserService.email(email);
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
type: 'GOOGLE_OAUTH',
emailVerified: true,
},
});
isNewUser = true;
}
if (user.type !== 'GOOGLE_OAUTH') {
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
}
// Send notification if this is a new user
if (isNewUser) {
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'Google');
}
const token = jwt.sign(user.id);
const cookie = UserService.cookieOptions();
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
}
}
+8
View File
@@ -0,0 +1,8 @@
import {ChildControllers, Controller} from '@overnightjs/core';
import {Github} from './Github.js';
import {Google} from './Google.js';
@Controller('oauth')
@ChildControllers([new Google(), new Github()])
export class Oauth {}
+264
View File
@@ -0,0 +1,264 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('projects')
export class Projects {
/**
* Get project setup state for dashboard quick start
* GET /projects/:id/setup-state
*/
@Get(':id/setup-state')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Get project with relevant data
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
_count: {
select: {
contacts: true,
domains: {
where: {verified: true},
},
workflows: {
where: {enabled: true},
},
},
},
},
});
if (!project) {
throw new HttpException(404, 'Project not found');
}
// Get last sent campaign
const lastCampaign = await prisma.campaign.findFirst({
where: {
projectId: id,
status: 'SENT',
sentAt: {not: null},
},
orderBy: {
sentAt: 'desc',
},
select: {
sentAt: true,
},
});
return res.json({
success: true,
data: {
hasSubscription: !!project.subscription,
hasVerifiedDomain: project._count.domains > 0,
contactCount: project._count.contacts,
lastCampaignSentAt: lastCampaign?.sentAt || null,
hasEnabledWorkflow: project._count.workflows > 0,
},
});
}
/**
* Get project security metrics
* GET /projects/:id/security
*/
@Get(':id/security')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Use existing SecurityService
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.json({
success: true,
data: metrics,
});
}
/**
* Get all members of a project
* GET /projects/:id/members
*/
@Get(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async getMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Get all members of the project
const members = await MembershipService.getMembers(id);
return res.json({
success: true,
data: members,
});
}
/**
* Add a member to a project by email
* POST /projects/:id/members
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
*/
@Post(':id/members')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async addMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
// Validate and parse request body
const parseResult = MembershipSchemas.addMember.safeParse(req.body);
if (!parseResult.success) {
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
}
const {email, role} = parseResult.data;
// Verify current user is ADMIN or OWNER
await MembershipService.requireAdminAccess(auth.userId!, id);
// Find user by email
const userToAdd = await prisma.user.findUnique({
where: {email: email.toLowerCase()},
select: {id: true, email: true},
});
if (!userToAdd) {
throw new HttpException(404, 'User with this email does not have an account');
}
// Add member to project
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
return res.json({
success: true,
data: {
userId: userToAdd.id,
email: userToAdd.email,
role: newMembership.role,
},
});
}
/**
* Update a member's role
* PATCH /projects/:id/members/:userId
* Body: { role: 'ADMIN' | 'MEMBER' }
*/
@Patch(':id/members/:userId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
if (!userId) {
throw new HttpException(400, 'User ID is required');
}
// Validate and parse request body
const parseResult = MembershipSchemas.updateRole.safeParse(req.body);
if (!parseResult.success) {
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
}
const {role} = parseResult.data;
// Verify current user is ADMIN or OWNER
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get user info
const user = await prisma.user.findUnique({
where: {id: userId},
select: {id: true, email: true},
});
if (!user) {
throw new HttpException(404, 'User not found');
}
// Update role (service handles validation)
await MembershipService.updateRole(id, userId, role);
return res.json({
success: true,
data: {
userId: user.id,
email: user.email,
role,
},
});
}
/**
* Remove a member from a project
* DELETE /projects/:id/members/:userId
*/
@Delete(':id/members/:userId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
private async removeMember(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id, userId} = req.params;
// Validate params
if (!id) {
throw new HttpException(400, 'Project ID is required');
}
if (!userId) {
throw new HttpException(400, 'User ID is required');
}
// Verify current user is ADMIN or OWNER
await MembershipService.requireAdminAccess(auth.userId!, id);
// Cannot remove yourself
if (userId === auth.userId) {
throw new HttpException(403, 'You cannot remove yourself from the project');
}
// Remove member (service handles validation)
await MembershipService.removeMember(id, userId);
return res.json({
success: true,
data: {message: 'Member removed successfully'},
});
}
}
+237
View File
@@ -0,0 +1,237 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {SegmentService} from '../services/SegmentService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('segments')
export class Segments {
/**
* GET /segments
* List all segments for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segments = await SegmentService.list(auth.projectId!);
return res.status(200).json(segments);
}
/**
* GET /segments/:id
* Get a specific segment by ID with member count
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const segment = await SegmentService.get(auth.projectId!, segmentId);
return res.status(200).json(segment);
}
/**
* GET /segments/:id/contacts
* Get contacts that match a segment's filters
*/
@Get(':id/contacts')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getContacts(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.getContacts(auth.projectId!, segmentId, page, pageSize);
return res.status(200).json(result);
}
/**
* POST /segments
* Create a new segment
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, description, type, condition, trackMembership} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
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,
type: segmentType,
condition: segmentType === 'DYNAMIC' ? condition : undefined,
trackMembership,
});
return res.status(201).json(segment);
}
/**
* PATCH /segments/:id
* Update a segment
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const {name, description, condition, trackMembership} = req.body;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
if (condition !== undefined && typeof condition !== 'object') {
return res.status(400).json({error: 'Condition must be an object'});
}
const segment = await SegmentService.update(auth.projectId!, segmentId, {
name,
description,
condition,
trackMembership,
});
return res.status(200).json(segment);
}
/**
* DELETE /segments/:id
* Delete a segment
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
await SegmentService.delete(auth.projectId!, segmentId);
return res.status(204).send();
}
/**
* POST /segments/:id/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
*/
@Post(':id/compute')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async compute(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const result = await SegmentService.computeMembership(auth.projectId!, segmentId);
return res.status(200).json(result);
}
/**
* POST /segments/:id/refresh
* Refresh segment member count
*/
@Post(':id/refresh')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async refresh(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
}
const memberCount = await SegmentService.refreshMemberCount(auth.projectId!, segmentId);
return res.status(200).json({memberCount});
}
}
+188
View File
@@ -0,0 +1,188 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {TemplateType} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {TemplateService} from '../services/TemplateService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('templates')
export class Templates {
/**
* GET /templates
* List all templates for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const type = req.query.type as TemplateType | undefined;
const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type);
return res.status(200).json(result);
}
/**
* GET /templates/:id
* Get a specific template by ID
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.get(auth.projectId!, templateId);
return res.status(200).json(template);
}
/**
* POST /templates
* Create a new template
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!subject) {
return res.status(400).json({error: 'Subject is required'});
}
if (!body) {
return res.status(400).json({error: 'Body is required'});
}
if (!from) {
return res.status(400).json({error: 'From address is required'});
}
// Verify domain ownership and verification
await DomainService.verifyEmailDomain(from, auth.projectId!);
const template = await TemplateService.create(auth.projectId!, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(201).json(template);
}
/**
* PATCH /templates/:id
* Update a template
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const templateId = req.params.id;
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
// Verify domain ownership and verification if 'from' is being updated
if (from) {
await DomainService.verifyEmailDomain(from, auth.projectId!);
}
const template = await TemplateService.update(auth.projectId!, templateId, {
name,
description,
subject,
body,
from,
fromName,
replyTo,
type,
});
return res.status(200).json(template);
}
/**
* DELETE /templates/:id
* Delete a template
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
await TemplateService.delete(auth.projectId!, templateId);
return res.status(204).send();
}
/**
* POST /templates/:id/duplicate
* Duplicate a template
*/
@Post(':id/duplicate')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async duplicate(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const template = await TemplateService.duplicate(auth.projectId!, templateId);
return res.status(201).json(template);
}
/**
* GET /templates/:id/usage
* Get template usage statistics
*/
@Get(':id/usage')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getUsage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const templateId = req.params.id;
if (!templateId) {
return res.status(400).json({error: 'Template ID is required'});
}
const usage = await TemplateService.getUsage(auth.projectId!, templateId);
return res.status(200).json(usage);
}
}
+93
View File
@@ -0,0 +1,93 @@
import {Controller, Middleware, Post} from '@overnightjs/core';
import type {NextFunction, Request, Response} from 'express';
import multer from 'multer';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import * as S3Service from '../services/S3Service.js';
import {CatchAsync} from '../utils/asyncHandler.js';
const MAGIC_BYTES: Record<string, Buffer[]> = {
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
'image/webp': [Buffer.from('RIFF')],
};
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
const signatures = MAGIC_BYTES[mimetype];
if (!signatures) return false;
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
}
// Configure multer for file uploads (memory storage)
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB max file size
},
fileFilter: (_req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
}
},
});
@Controller('uploads')
export class Uploads {
/**
* POST /uploads/image
* Upload an image file to S3/Minio
*/
@Post('image')
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
@CatchAsync
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
try {
if (!S3Service.isS3Enabled()) {
return res.status(503).json({
error: 'File uploads are not enabled. Please configure S3 storage.',
});
}
if (!req.file) {
return res.status(400).json({
error: 'No image file provided',
});
}
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
return res.status(400).json({
error: 'File contents do not match the declared image type',
});
}
// Upload file to S3/Minio
const result = await S3Service.uploadFile({
file: req.file.buffer,
filename: req.file.originalname,
contentType: req.file.mimetype,
projectId: auth.projectId!,
});
return res.status(200).json({
url: result.url,
key: result.key,
filename: req.file.originalname,
contentType: req.file.mimetype,
size: req.file.size,
});
} catch (error) {
signale.error('[UPLOADS] Failed to upload image:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to upload image',
});
}
}
}
+731
View File
@@ -0,0 +1,731 @@
import {randomBytes} from 'node:crypto';
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
import {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';
import {CatchAsync} from '../utils/asyncHandler.js';
import signale from 'signale';
@Controller('users')
export class Users {
@Get('@me')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async me(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
}
const me = await UserService.id(auth.userId);
if (!me) {
throw new NotAuthenticated();
}
return res.status(200).json({id: me.id, email: me.email});
}
@Get('@me/projects')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async meProjects(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
}
const projects = await UserService.projects(auth.userId);
return res.status(200).json(projects);
}
@Post('@me/projects')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
if (!auth.userId) {
throw new NotAuthenticated();
}
// Check if user is a member of any disabled project
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
ErrorCode.PROJECT_DISABLED,
);
}
const {name} = ProjectSchemas.create.parse(req.body);
// Generate unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Create the project
const project = await prisma.project.create({
data: {
name,
public: publicKey,
secret: secretKey,
members: {
create: {
userId: auth.userId,
role: 'OWNER',
},
},
},
});
// Send notification about project creation
await NtfyService.notifyProjectCreated(project.name, project.id, auth.userId);
return res.status(201).json(project);
}
@Patch('@me/projects/:id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const data = ProjectSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Update the project
const project = await prisma.project.update({
where: {id},
data,
});
return res.status(200).json(project);
}
@Post('@me/projects/:id/regenerate-keys')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Generate new unique API keys
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
// Update the project with new keys
const project = await prisma.project.update({
where: {id},
data: {
public: publicKey,
secret: secretKey,
},
select: {
id: true,
name: true,
public: true,
secret: true,
createdAt: true,
updatedAt: true,
disabled: true,
customer: true,
subscription: true,
},
});
// Send notification about API key regeneration
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
return res.status(200).json(project);
}
@Post('@me/projects/:id/checkout')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {currency} = req.query;
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// If project already has a subscription, return error
if (project.subscription) {
return res.status(400).json({error: 'Project already has a subscription'});
}
// Build line items for subscription
const lineItems = [];
// Add one-time onboarding fee if configured
if (STRIPE_PRICE_ONBOARDING) {
lineItems.push({price: STRIPE_PRICE_ONBOARDING, quantity: 1});
}
// Add metered pricing for pay-per-email (required)
if (!STRIPE_PRICE_EMAIL_USAGE) {
return res.status(500).json({error: 'Usage-based pricing not configured. Set STRIPE_PRICE_EMAIL_USAGE.'});
}
lineItems.push({price: STRIPE_PRICE_EMAIL_USAGE}); // No quantity for metered items
// Calculate billing cycle anchor to first day of next month
// This ensures all subscriptions renew on the 1st of each month
const now = new Date();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
// Validate currency if provided
let checkoutCurrency: string | undefined;
if (currency && typeof currency === 'string') {
const validCurrencies = ['usd', 'eur', 'gbp'];
if (validCurrencies.includes(currency.toLowerCase())) {
checkoutCurrency = currency.toLowerCase();
} else {
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
}
}
// Create checkout session
// Note: proration_behavior cannot be set when one-time prices are included
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: project.customer ?? undefined, // Use existing customer if available
client_reference_id: project.id, // Store project ID for webhook
line_items: lineItems,
...(checkoutCurrency && {currency: checkoutCurrency}),
subscription_data: {
billing_cycle_anchor: billingCycleAnchor,
},
success_url: `${DASHBOARD_URI}/settings?tab=billing&success=true`,
cancel_url: `${DASHBOARD_URI}/settings?tab=billing&canceled=true`,
});
return res.status(200).json({url: session.url});
}
@Post('@me/projects/:id/billing-portal')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
where: {id},
});
if (!project) {
throw new NotFound('Project not found');
}
// Project must have a customer ID to access billing portal
if (!project.customer) {
return res.status(400).json({error: 'No customer found for this project'});
}
// Create billing portal session
const session = await stripe.billingPortal.sessions.create({
customer: project.customer,
return_url: `${DASHBOARD_URI}/settings?tab=billing`,
});
return res.status(200).json({url: session.url});
}
@Get('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Get billing limits and usage
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Put('@me/projects/:id/billing-limits')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
const data = BillingLimitSchemas.update.parse(req.body);
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get the project with current limits
const project = await prisma.project.findUnique({
where: {id},
select: {
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
// Require active subscription to set billing limits
if (!project.subscription) {
return res.status(400).json({error: 'An active subscription is required to set billing limits'});
}
// Update billing limits
await prisma.project.update({
where: {id},
data: {
billingLimitWorkflows: data.workflows,
billingLimitCampaigns: data.campaigns,
billingLimitTransactional: data.transactional,
billingLimitInbound: data.inbound,
},
});
// Clear notification cache keys for limits that changed
// This allows new warning/limit emails to be sent when the new limits are reached
await BillingLimitService.clearNotificationCacheForChangedLimits(
id,
{
workflows: project.billingLimitWorkflows,
campaigns: project.billingLimitCampaigns,
transactional: project.billingLimitTransactional,
inbound: project.billingLimitInbound,
},
data,
);
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
return res.status(200).json(limitsAndUsage);
}
@Get('@me/projects/:id/billing-consumption')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
subscription: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer || !project.subscription) {
return res.status(400).json({error: 'No active subscription found'});
}
// Get the current billing period (start of current month to now)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
// Get upcoming invoice to see costs and usage
let upcomingInvoice = null;
let totalUsage = 0;
try {
upcomingInvoice = (await stripe.invoices.createPreview({
customer: project.customer,
subscription: project.subscription,
})) as any;
// Extract metered usage from invoice line items
if (upcomingInvoice && upcomingInvoice.lines && upcomingInvoice.lines.data) {
// Since we only have one meter (email usage), we can safely use the first line item
// The invoice preview doesn't expand the price object, so we can't match by price ID
const meteredLine = upcomingInvoice.lines.data[0];
if (meteredLine && meteredLine.quantity) {
totalUsage = meteredLine.quantity;
}
}
} catch (error) {
// No upcoming invoice yet or error retrieving it
// This is normal for new subscriptions or if there's no usage yet
signale.error('[BILLING] Error retrieving upcoming invoice:', error);
}
// Get customer to retrieve balance (credits)
const customer = await stripe.customers.retrieve(project.customer);
let credits = null;
if (!customer.deleted) {
// Stripe stores balance in cents as a negative number (credit) or positive (debit)
// A negative balance means the customer has credits
const balance = customer.balance || 0;
const hasCredits = balance < 0;
const creditAmount = hasCredits ? Math.abs(balance) : 0;
credits = {
balance,
hasCredits,
creditAmount,
currency: customer.currency || 'usd',
};
}
const responseData = {
period: {
start: startOfMonth.toISOString(),
end: now.toISOString(),
},
usage: {
total: totalUsage,
records: [], // Deprecated in favor of invoice line items
},
credits,
upcomingInvoice: upcomingInvoice
? {
amountDue: upcomingInvoice.amount_due,
currency: upcomingInvoice.currency,
periodStart: upcomingInvoice.period_start
? new Date(upcomingInvoice.period_start * 1000).toISOString()
: startOfMonth.toISOString(),
periodEnd: upcomingInvoice.period_end
? new Date(upcomingInvoice.period_end * 1000).toISOString()
: now.toISOString(),
subtotal: upcomingInvoice.subtotal ?? 0,
total: upcomingInvoice.total ?? 0,
}
: null,
};
return res.status(200).json(responseData);
}
@Get('@me/projects/:id/billing-invoices')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
// Check if billing is enabled
if (!STRIPE_ENABLED || !stripe) {
return res.status(404).json({error: 'Billing is not enabled'});
}
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Get the project
const project = await prisma.project.findUnique({
where: {id},
select: {
customer: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
if (!project.customer) {
return res.status(400).json({error: 'No customer found'});
}
// Get all invoices for this customer
const invoices = await stripe.invoices.list({
customer: project.customer,
limit: 100,
});
// Check for unpaid invoices
const unpaidInvoices = invoices.data.filter(
invoice => invoice.status === 'open' || invoice.status === 'uncollectible',
);
return res.status(200).json({
invoices: invoices.data.map(invoice => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: new Date(invoice.created * 1000).toISOString(),
periodStart: invoice.period_start ? new Date(invoice.period_start * 1000).toISOString() : null,
periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
subtotal: invoice.subtotal,
total: invoice.total,
paid: invoice.status === 'paid',
})),
hasUnpaidInvoices: unpaidInvoices.length > 0,
unpaidInvoices: unpaidInvoices.map(invoice => ({
id: invoice.id,
number: invoice.number,
amountDue: invoice.amount_due,
currency: invoice.currency,
dueDate: invoice.due_date ? new Date(invoice.due_date * 1000).toISOString() : null,
hostedInvoiceUrl: invoice.hosted_invoice_url,
})),
});
}
@Get('@me/projects/:id/security')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has access to this project
await MembershipService.requireAccess(auth.userId!, id);
// Get security metrics
const metrics = await SecurityService.getProjectSecurityMetrics(id);
return res.status(200).json(metrics);
}
@Post('@me/projects/:id/reset')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async resetProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has admin/owner access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Check if project is disabled - block reset operation
const isDisabled = await SecurityService.isProjectDisabled(id);
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
ErrorCode.PROJECT_DISABLED,
);
}
// Delete all project data in a transaction
await prisma.$transaction(async tx => {
// Delete all emails
await tx.email.deleteMany({
where: {projectId: id},
});
// Delete all events
await tx.event.deleteMany({
where: {projectId: id},
});
// Delete all campaigns
await tx.campaign.deleteMany({
where: {projectId: id},
});
// Delete all workflows
await tx.workflow.deleteMany({
where: {projectId: id},
});
// Delete all segments
await tx.segment.deleteMany({
where: {projectId: id},
});
// Delete all contacts
await tx.contact.deleteMany({
where: {projectId: id},
});
// Delete all templates
await tx.template.deleteMany({
where: {projectId: id},
});
// Delete all API requests
await tx.apiRequest.deleteMany({
where: {projectId: id},
});
});
return res.status(200).json({success: true, message: 'Project reset successfully'});
}
@Delete('@me/projects/:id')
@Middleware([isAuthenticated, requireEmailVerified])
@CatchAsync
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
if (!auth.userId) {
throw new NotAuthenticated();
}
if (!id) {
throw new NotFound('Project ID is required');
}
// Verify user has owner or admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, id);
// Get project to check for active subscription and disabled status
const project = await prisma.project.findUnique({
where: {id},
select: {
name: true,
subscription: true,
customer: true,
disabled: true,
},
});
if (!project) {
throw new NotFound('Project not found');
}
// Check if project is disabled - block delete operation
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
ErrorCode.PROJECT_DISABLED,
);
}
// If project has an active subscription, cancel it first
if (STRIPE_ENABLED && stripe && project.subscription) {
try {
await stripe.subscriptions.cancel(project.subscription);
} catch (error) {
// Log but don't fail if subscription cancellation fails
signale.error('Failed to cancel subscription:', error);
}
}
// Delete the project (cascading deletes will handle related data)
await prisma.project.delete({
where: {id},
});
// Send notification about project deletion
await NtfyService.notifyProjectDeleted(project.name, id, auth.userId);
return res.status(200).json({success: true, message: 'Project deleted successfully'});
}
}
+641
View File
@@ -0,0 +1,641 @@
import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import signale from 'signale';
import type Stripe from 'stripe';
import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import {DASHBOARD_URI, LANDING_URI, 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 {MembershipService} from '../services/MembershipService.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';
/**
* Webhooks Controller
* Handles incoming webhooks from external services (AWS SNS/SES)
*/
@Controller('webhooks')
export class Webhooks {
/**
* Receive SNS webhook notifications from AWS SES
* Handles outbound email events: delivery, open, click, bounce, complaint
* Handles inbound email notifications: received emails via SES receiving
*/
@Post('sns')
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Handle SNS subscription confirmation FIRST (before parsing Message field)
if (req.body.Type === 'SubscriptionConfirmation') {
signale.info('SNS Subscription Confirmation received');
// 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 or sns.<region>.amazonaws.eu
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
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(subscribeURL);
if (confirmResponse.ok) {
signale.success('SNS subscription confirmed successfully');
return res.status(200).json({
success: true,
message: 'Subscription confirmed',
});
} else {
signale.error('Failed to confirm SNS subscription:', confirmResponse.statusText);
return res.status(200).json({
success: false,
message: 'Failed to confirm subscription',
});
}
} catch (confirmError) {
signale.error('Error confirming SNS subscription:', confirmError);
return res.status(200).json({
success: false,
message: 'Error confirming subscription',
});
}
}
// Handle SNS notification messages - parse the Message field
if (req.body.Type !== 'Notification') {
signale.warn('[WEBHOOK] Unknown SNS message type:', req.body.Type);
return res.status(200).json({success: false, message: 'Unknown message type'});
}
// Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message);
// 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;
if (!messageId) {
signale.warn('[WEBHOOK] No messageId found in SNS notification');
return res.status(400).json({success: false, error: 'No messageId found'});
}
// Look up email by SES messageId
const email = await prisma.email.findUnique({
where: {messageId},
include: {
contact: true,
project: true,
},
});
if (!email) {
signale.warn(`[WEBHOOK] Email not found for messageId: ${messageId}`);
return res.status(404).json({success: false, error: 'Email not found'});
}
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
const eventName = `email.${eventType.toLowerCase()}`;
// Base event data with email metadata
const baseEventData = {
subject: email.subject,
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
};
let eventData: Record<string, unknown> = baseEventData;
// Process event based on type
switch (eventType) {
case 'Delivery':
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
eventData = {
...baseEventData,
deliveredAt: now.toISOString(),
};
break;
case 'Open':
signale.success(`[WEBHOOK] Open received for ${email.contact.email} from ${email.project.name}`);
// Only set openedAt on first open
if (!email.openedAt) {
updateData.openedAt = now;
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
eventData = {
...baseEventData,
openedAt: email.openedAt?.toISOString() || now.toISOString(),
opens: (email.opens || 0) + 1,
isFirstOpen: !email.openedAt,
};
break;
case 'Click': {
signale.success(`[WEBHOOK] Click received for ${email.contact.email} from ${email.project.name}`);
const clickedLink = body.click?.link;
// Only set clickedAt on first click
if (!email.clickedAt) {
updateData.clickedAt = now;
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
eventData = {
...baseEventData,
link: clickedLink,
clickedAt: email.clickedAt?.toISOString() || now.toISOString(),
clicks: (email.clicks || 0) + 1,
isFirstClick: !email.clickedAt,
};
break;
}
case 'Bounce': {
const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.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}`);
updateData.status = EmailStatus.COMPLAINED;
updateData.complainedAt = now;
// Unsubscribe contact on complaint
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
complainedAt: now.toISOString(),
};
// Send notification about complaint
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
break;
default:
signale.warn(`[WEBHOOK] Unknown event type: ${eventType}`);
return res.status(200).json({success: true});
}
// Update email with new status and timestamps
await prisma.email.update({
where: {id: email.id},
data: updateData,
});
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits 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);
}
signale.success(`[WEBHOOK] Processed ${eventType} event for email ${email.id}`);
return res.status(200).json({success: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing SNS webhook:', error);
// Always return 200 to prevent SNS from retrying
return res.status(200).json({success: true});
}
}
/**
* Receive Stripe webhook notifications
* Handles subscription and payment events: checkout.session.completed, invoice.paid, etc.
*/
@Post('incoming/stripe')
@CatchAsync
public async receiveStripeWebhook(req: Request, res: Response) {
// Return 404 if billing is disabled
if (!STRIPE_ENABLED || !stripe) {
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
return res.status(404).json({success: false, error: 'Billing is disabled'});
}
try {
const sig = req.headers['stripe-signature'];
if (!sig) {
signale.warn('[WEBHOOK] Missing Stripe signature header');
return res.status(400).json({success: false, error: 'Missing signature'});
}
// Verify webhook signature using raw body
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
signale.error('[WEBHOOK] Stripe signature verification failed:', err);
return res.status(400).json({success: false, error: 'Invalid signature'});
}
signale.info(`[WEBHOOK] Received Stripe event: ${event.type}`);
// Handle different event types
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
const projectId = session.client_reference_id; // Assuming project ID is passed as reference
if (!projectId) {
signale.warn('[WEBHOOK] No client_reference_id in checkout session');
break;
}
// Update project with customer and subscription IDs
const updatedProject = await prisma.project.update({
where: {id: projectId},
data: {
customer: customerId,
subscription: subscriptionId,
},
});
// Update Stripe customer name to match project name and add credit for onboarding fee
await stripe.customers.update(customerId, {
name: updatedProject.name,
balance: -100,
});
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
// Send notification about subscription started
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
break;
}
case 'invoice.paid': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
// Send notification about invoice payment
await NtfyService.notifyInvoicePaid(project.name, project.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const customerId = invoice.customer as string;
// Only disable projects that are already consuming (recurring billing).
// If billing_reason is 'subscription_create', this is a first-time payment
// attempt and the project has never had an active subscription — don't disable.
if (invoice.billing_reason === 'subscription_create') {
signale.info(
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
);
break;
}
// Find project by customer ID
const project = await prisma.project.findUnique({
where: {customer: customerId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
break;
}
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
await prisma.project.update({
where: {id: project.id},
data: {disabled: true},
});
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledPaymentEmail, {
projectName: project.name,
projectId: project.id,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
);
}
} catch (emailError) {
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
// Clear subscription from project
await prisma.project.update({
where: {id: project.id},
data: {
subscription: null,
},
});
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
// Send notification about subscription cancellation
await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const subscriptionId = subscription.id;
// Find project by subscription ID
const project = await prisma.project.findUnique({
where: {subscription: subscriptionId},
});
if (!project) {
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
break;
}
signale.info(`[WEBHOOK] Subscription updated for project ${project.name} (${project.id})`);
signale.info(
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
);
// Send notification about subscription update
await NtfyService.notifySubscriptionUpdated(project.name, project.id);
break;
}
// Unhandled events
default:
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
break;
}
return res.status(200).json({success: true, received: true});
} catch (error) {
signale.error('[WEBHOOK] Error processing Stripe webhook:', error);
return res.status(400).json({success: false, error: 'Webhook processing failed'});
}
}
}
+392
View File
@@ -0,0 +1,392 @@
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
import {WorkflowExecutionStatus} from '@plunk/db';
import type {NextFunction, Request, Response} from 'express';
import signale from 'signale';
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('workflows')
export class Workflows {
/**
* GET /workflows
* List all workflows for the authenticated project
*/
@Get('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async list(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const search = req.query.search as string | undefined;
const result = await WorkflowService.list(auth.projectId!, page, pageSize, search);
return res.status(200).json(result);
}
/**
* GET /workflows/fields
* Get all available fields for workflow conditions (contact fields + event fields)
* Query param: eventName - Optional event name to filter event fields
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/
@Get('fields')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const eventName = req.query.eventName as string | undefined;
try {
const result = await WorkflowService.getAvailableFields(auth.projectId!, eventName);
return res.status(200).json(result);
} catch (error) {
signale.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /workflows/:id
* Get a specific workflow with all steps and transitions
*/
@Get(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async get(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.get(auth.projectId!, workflowId);
return res.status(200).json(workflow);
}
/**
* POST /workflows
* Create a new workflow
*/
@Post('')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, description, eventName, enabled, allowReentry} = req.body;
if (!name) {
return res.status(400).json({error: 'Name is required'});
}
if (!eventName) {
return res.status(400).json({error: 'Event name is required'});
}
const workflow = await WorkflowService.create(auth.projectId!, {
name,
description,
eventName,
enabled,
allowReentry,
});
return res.status(201).json(workflow);
}
/**
* PATCH /workflows/:id
* Update a workflow
*/
@Patch(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const workflow = await WorkflowService.update(auth.projectId!, workflowId, {
name,
description,
triggerType,
triggerConfig,
enabled,
allowReentry,
});
return res.status(200).json(workflow);
}
/**
* DELETE /workflows/:id
* Delete a workflow
*/
@Delete(':id')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async delete(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
await WorkflowService.delete(auth.projectId!, workflowId);
return res.status(204).send();
}
/**
* POST /workflows/:id/steps
* Add a step to a workflow
*/
@Post(':id/steps')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async addStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const {type, name, position, config, templateId, autoConnect} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!type || !name || !position || !config) {
return res.status(400).json({error: 'Type, name, position, and config are required'});
}
const step = await WorkflowService.addStep(auth.projectId!, workflowId, {
type,
name,
position,
config,
templateId,
autoConnect,
});
return res.status(201).json(step);
}
/**
* PATCH /workflows/:id/steps/:stepId
* Update a workflow step
*/
@Patch(':id/steps/:stepId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async updateStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const {name, position, config, templateId} = req.body;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
const step = await WorkflowService.updateStep(auth.projectId!, workflowId, stepId, {
name,
position,
config,
templateId,
});
return res.status(200).json(step);
}
/**
* DELETE /workflows/:id/steps/:stepId
* Delete a workflow step
*/
@Delete(':id/steps/:stepId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
return res.status(204).send();
}
/**
* POST /workflows/:id/transitions
* Create a transition between steps
*/
@Post(':id/transitions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async createTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const {fromStepId, toStepId, condition, priority} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!fromStepId || !toStepId) {
return res.status(400).json({error: 'From step ID and to step ID are required'});
}
const transition = await WorkflowService.createTransition(auth.projectId!, workflowId, {
fromStepId,
toStepId,
condition,
priority,
});
return res.status(201).json(transition);
}
/**
* DELETE /workflows/:id/transitions/:transitionId
* Delete a transition
*/
@Delete(':id/transitions/:transitionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const transitionId = req.params.transitionId;
if (!workflowId || !transitionId) {
return res.status(400).json({error: 'Workflow ID and Transition ID are required'});
}
await WorkflowService.deleteTransition(auth.projectId!, workflowId, transitionId);
return res.status(204).send();
}
/**
* POST /workflows/:id/executions
* Start a workflow execution for a contact
*/
@Post(':id/executions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async startExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const {contactId, context} = req.body;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
if (!contactId) {
return res.status(400).json({error: 'Contact ID is required'});
}
const execution = await WorkflowService.startExecution(auth.projectId!, workflowId, contactId, context);
return res.status(201).json(execution);
}
/**
* GET /workflows/:id/executions
* List executions for a workflow
*/
@Get(':id/executions')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const page = parseInt(req.query.page as string) || 1;
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
const status = req.query.status as WorkflowExecutionStatus | undefined;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.listExecutions(auth.projectId!, workflowId, page, pageSize, status);
return res.status(200).json(result);
}
/**
* GET /workflows/:id/executions/:executionId
* Get a specific execution with details
*/
@Get(':id/executions/:executionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async getExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.getExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
/**
* DELETE /workflows/:id/executions/:executionId
* Cancel a workflow execution
*/
@Delete(':id/executions/:executionId')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
const executionId = req.params.executionId;
if (!workflowId || !executionId) {
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
}
const execution = await WorkflowService.cancelExecution(auth.projectId!, workflowId, executionId);
return res.status(200).json(execution);
}
/**
* POST /workflows/:id/executions/cancel-all
* Cancel all active executions for a workflow
*/
@Post(':id/executions/cancel-all')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const workflowId = req.params.id;
if (!workflowId) {
return res.status(400).json({error: 'Workflow ID is required'});
}
const result = await WorkflowService.cancelAllExecutions(auth.projectId!, workflowId);
return res.status(200).json(result);
}
}
@@ -0,0 +1,222 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories} from '../../../../../test/helpers';
import {EventService} from '../../services/EventService';
import {WorkflowService} from '../../services/WorkflowService';
describe('Workflows Controller', () => {
let projectId: string;
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// GET /workflows/fields
// ========================================
describe('GET /workflows/fields', () => {
it('should return contact and event fields', async () => {
const contact = await factories.createContact({
projectId,
data: {
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
},
});
// Create email events with data
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Welcome',
from: 'hello@example.com',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
expect(responseBody.fields).toBeInstanceOf(Array);
expect(responseBody.count).toBeGreaterThan(0);
// Should include standard contact fields
expect(responseBody.fields).toContain('contact.email');
expect(responseBody.fields).toContain('contact.subscribed');
// Should include custom contact data fields
expect(responseBody.fields).toContain('contact.data.firstName');
expect(responseBody.fields).toContain('contact.data.lastName');
expect(responseBody.fields).toContain('contact.data.plan');
// Should include event fields
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.from');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.isFirstOpen');
});
it('should filter event fields by eventName query param', async () => {
const contact = await factories.createContact({projectId});
// Create email.opened events
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Test',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
// Create email.clicked events
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
subject: 'Test',
link: 'https://example.com',
clickedAt: new Date().toISOString(),
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
// Should include email.opened fields
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.isFirstOpen');
// Should NOT include email.clicked fields
expect(responseBody.fields).not.toContain('event.link');
expect(responseBody.fields).not.toContain('event.clickedAt');
});
it('should return fields in sorted order', async () => {
const contact = await factories.createContact({
projectId,
data: {
zebra: 'last',
apple: 'first',
},
});
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
zoo: 'value',
aardvark: 'value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
const fields = responseBody.fields as string[];
// Verify fields are sorted
const sortedFields = [...fields].sort();
expect(fields).toEqual(sortedFields);
});
it('should return empty event fields when no events exist', async () => {
const responseBody = await WorkflowService.getAvailableFields(projectId);
// Should still have contact fields
expect(responseBody.fields).toContain('contact.email');
expect(responseBody.fields).toContain('contact.subscribed');
// But no event fields
const eventFields = responseBody.fields.filter((f: string) => f.startsWith('event.'));
expect(eventFields).toHaveLength(0);
});
it('should only return fields for authenticated project', async () => {
// Create another project with events
const {project: otherProject} = await factories.createUserWithProject();
const otherContact = await factories.createContact({projectId: otherProject.id});
await EventService.trackEvent(otherProject.id, 'other.event', otherContact.id, undefined, {
secretField: 'secret value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId);
// Should not include fields from other project
expect(responseBody.fields).not.toContain('event.secretField');
});
it('should handle URL encoded event names', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
field1: 'value',
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
expect(responseBody.fields).toContain('event.field1');
});
});
// ========================================
// Integration Tests
// ========================================
describe('Workflow field discovery integration', () => {
it('should discover correct fields for email.sent events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
// Create email.sent event with actual structure
await EventService.trackEvent(projectId, 'email.sent', contact.id, email.id, {
subject: 'Welcome Email',
from: 'hello@example.com',
fromName: 'Example Team',
messageId: 'msg-123',
templateId: 'tpl-456',
campaignId: null,
sourceType: 'TRANSACTIONAL',
sentAt: new Date().toISOString(),
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.sent');
expect(responseBody.fields).toContain('event.subject');
expect(responseBody.fields).toContain('event.from');
expect(responseBody.fields).toContain('event.fromName');
expect(responseBody.fields).toContain('event.messageId');
expect(responseBody.fields).toContain('event.templateId');
expect(responseBody.fields).toContain('event.sourceType');
expect(responseBody.fields).toContain('event.sentAt');
});
it('should discover correct fields for email.opened events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
subject: 'Newsletter',
from: 'news@example.com',
fromName: 'Newsletter Team',
messageId: 'msg-456',
openedAt: new Date().toISOString(),
opens: 1,
isFirstOpen: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
expect(responseBody.fields).toContain('event.openedAt');
expect(responseBody.fields).toContain('event.opens');
expect(responseBody.fields).toContain('event.isFirstOpen');
});
it('should discover correct fields for email.clicked events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.clicked', contact.id, email.id, {
subject: 'Sale Announcement',
from: 'sales@example.com',
link: 'https://example.com/sale',
clickedAt: new Date().toISOString(),
clicks: 1,
isFirstClick: true,
});
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.clicked');
expect(responseBody.fields).toContain('event.link');
expect(responseBody.fields).toContain('event.clickedAt');
expect(responseBody.fields).toContain('event.clicks');
expect(responseBody.fields).toContain('event.isFirstClick');
});
});
});
+3
View File
@@ -0,0 +1,3 @@
import {PrismaClient} from '@prisma/client';
export const prisma = new PrismaClient();
@@ -1,19 +1,12 @@
import Redis from 'ioredis';
import {REDIS_URL} from '../app/constants';
import signale from "signale";
import {Redis} from 'ioredis';
let redis: Redis;
try {
redis = new Redis(REDIS_URL);
const infoString = redis.info();
signale.info('Redis initialized: ', infoString);
} catch (error) {
signale.error('Failed to initialize Redis: ', error);
}
export {redis};
import {REDIS_URL} from '../app/constants.js';
export const redis = new Redis(REDIS_URL + '?family=0');
export const REDIS_ONE_MINUTE = 60;
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE;
export const TEN_MINUTES_IN_SECONDS = 60 * 10;
/**
* @param key The key for redis (use Keys#<type>)
@@ -34,3 +27,15 @@ export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds =
return recent;
}
export const cache = {
set<T>(key: string, value: T, seconds = REDIS_DEFAULT_EXPIRY): Promise<'OK' | null> {
return redis.set(key, JSON.stringify(value), 'EX', seconds);
},
incr(key: string): Promise<number> {
return redis.incr(key);
},
decr(key: string): Promise<number> {
return redis.decr(key);
},
};
+167
View File
@@ -0,0 +1,167 @@
/**
* Machine-readable error codes for programmatic error handling
* These codes allow API consumers to handle specific error types reliably
*/
export enum ErrorCode {
// Authentication & Authorization (401-403)
UNAUTHORIZED = 'UNAUTHORIZED',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
MISSING_AUTH = 'MISSING_AUTH',
INVALID_API_KEY = 'INVALID_API_KEY',
FORBIDDEN = 'FORBIDDEN',
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
PROJECT_DISABLED = 'PROJECT_DISABLED',
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
// Resource Errors (404-409)
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
CONTACT_NOT_FOUND = 'CONTACT_NOT_FOUND',
TEMPLATE_NOT_FOUND = 'TEMPLATE_NOT_FOUND',
CAMPAIGN_NOT_FOUND = 'CAMPAIGN_NOT_FOUND',
WORKFLOW_NOT_FOUND = 'WORKFLOW_NOT_FOUND',
CONFLICT = 'CONFLICT',
// Validation & Input Errors (400, 422)
BAD_REQUEST = 'BAD_REQUEST',
VALIDATION_ERROR = 'VALIDATION_ERROR',
INVALID_EMAIL = 'INVALID_EMAIL',
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
// Rate Limiting & Billing (429, 402)
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
BILLING_LIMIT_EXCEEDED = 'BILLING_LIMIT_EXCEEDED',
UPGRADE_REQUIRED = 'UPGRADE_REQUIRED',
// Server Errors (500+)
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
DATABASE_ERROR = 'DATABASE_ERROR',
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
}
/**
* Structured field-level validation error
*/
export interface FieldError {
field: string; // Dot-notation path (e.g., "email", "data.firstName")
message: string; // Human-readable error message
code: string; // Validation error code (e.g., "invalid_email", "too_short")
received?: unknown; // The invalid value that was provided
}
/**
* Base HTTP exception with support for error codes and structured data
*/
export class HttpException extends Error {
public constructor(
public readonly code: number,
message: string,
public readonly errorCode?: ErrorCode,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
}
}
/**
* Resource not found (404)
*/
export class NotFound extends HttpException {
/**
* Construct a new NotFound exception
* @param resource The type of resource that was not found
* @param id Optional resource identifier to include in the message
*/
public constructor(resource: string, id?: string) {
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
// Map common resources to specific error codes
const errorCodeMap: Record<string, ErrorCode> = {
contact: ErrorCode.CONTACT_NOT_FOUND,
template: ErrorCode.TEMPLATE_NOT_FOUND,
campaign: ErrorCode.CAMPAIGN_NOT_FOUND,
workflow: ErrorCode.WORKFLOW_NOT_FOUND,
};
const errorCode = errorCodeMap[resource.toLowerCase()] || ErrorCode.RESOURCE_NOT_FOUND;
super(404, message, errorCode, {resource, id});
}
}
/**
* Forbidden - user is not allowed to perform this action (403)
*/
export class NotAllowed extends HttpException {
/**
* Construct a new NotAllowed exception
* @param msg Custom error message
* @param reason Optional reason for the forbidden action
*/
public constructor(msg = 'You are not allowed to perform this action', reason?: string) {
super(403, msg, ErrorCode.FORBIDDEN, reason ? {reason} : undefined);
}
}
/**
* Unauthorized - user needs to authenticate (401)
*/
export class NotAuthenticated extends HttpException {
public constructor(message = 'You need to be authenticated to do this', errorCode = ErrorCode.UNAUTHORIZED) {
super(401, message, errorCode);
}
}
/**
* Payment required - user needs to upgrade plan (402)
*/
export class NeedToUpgrade extends HttpException {
public constructor(msg = 'You need to upgrade your plan to do this') {
super(402, msg, ErrorCode.UPGRADE_REQUIRED);
}
}
/**
* Validation error with field-level details (422)
*/
export class ValidationError extends HttpException {
/**
* Construct a new ValidationError exception
* @param errors Array of field-level validation errors
* @param message Optional override for the main error message
*/
public constructor(
public readonly errors: FieldError[],
message = 'Validation failed',
) {
super(422, message, ErrorCode.VALIDATION_ERROR, {errors});
}
}
/**
* Conflict - resource already exists or state conflict (409)
*/
export class ConflictError extends HttpException {
public constructor(message: string, details?: Record<string, unknown>) {
super(409, message, ErrorCode.CONFLICT, details);
}
}
/**
* Rate limit exceeded (429)
*/
export class RateLimitError extends HttpException {
public constructor(message = 'Rate limit exceeded. Please try again later.', retryAfter?: number) {
super(429, message, ErrorCode.RATE_LIMIT_EXCEEDED, retryAfter ? {retryAfter} : undefined);
}
}
/**
* Bad request - generic client error (400)
*/
export class BadRequest extends HttpException {
public constructor(message: string, errorCode = ErrorCode.BAD_REQUEST, details?: Record<string, unknown>) {
super(400, message, errorCode, details);
}
}
@@ -0,0 +1,340 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import {toPrismaJson} from '@plunk/types';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
// Mock MeterService
vi.mock('../../services/MeterService.js', () => ({
MeterService: {
recordEmailSent: vi.fn().mockResolvedValue(undefined),
},
}));
describe('Email Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const _serviceMocks = createServiceMocks();
beforeEach(async () => {
const {project} = await factories.createUserWithProject({}, {tracking: TrackingMode.ENABLED});
projectId = project.id;
});
describe('Email Processing', () => {
it('should process a pending email', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
subject: 'Test Email',
body: '<p>Hello {{firstName}}</p>',
status: EmailStatus.PENDING,
});
// Simulate processing
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Simulate successful send
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
},
});
const processed = await prisma.email.findUnique({where: {id: email.id}});
expect(processed?.status).toBe(EmailStatus.SENT);
expect(processed?.sentAt).toBeDefined();
});
it('should skip emails that are not pending', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.SENT, // Already sent
});
// Processor should skip this email
const shouldProcess = email.status === EmailStatus.PENDING;
expect(shouldProcess).toBe(false);
});
it('should fail email if project is disabled', async () => {
// Create project with disabled flag
const {project: disabledProject} = await factories.createUserWithProject({}, {disabled: true});
const contact = await factories.createContact({projectId: disabledProject.id});
const email = await factories.createEmail(disabledProject.id, contact.id, {
status: EmailStatus.PENDING,
});
// Verify project is disabled
const project = await prisma.project.findUnique({
where: {id: disabledProject.id},
});
expect(project?.disabled).toBe(true);
// Processor should fail this email
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe('Project is disabled');
});
it('should handle campaign emails', async () => {
const contact = await factories.createContact({projectId});
const campaign = await factories.createCampaign({projectId});
const email = await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
});
expect(email.campaignId).toBe(campaign.id);
// Process the email
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
const sent = await prisma.email.findUnique({where: {id: email.id}});
expect(sent?.status).toBe(EmailStatus.SENT);
});
it('should handle transactional emails without unsubscribe', async () => {
const contact = await factories.createContact({projectId});
const template = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
await factories.createEmail(projectId, contact.id, {
templateId: template.id,
status: EmailStatus.PENDING,
});
expect(template.type).toBe('TRANSACTIONAL');
});
});
describe('Email Status Transitions', () => {
it('should transition PENDING -> SENDING -> SENT', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
expect(email.status).toBe(EmailStatus.PENDING);
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
let updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENDING);
// Transition to SENT
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENT);
expect(updated?.sentAt).toBeDefined();
});
it('should handle PENDING -> SENDING -> FAILED', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Fail with error
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'SES send failed: Invalid email address',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toContain('SES send failed');
});
});
describe('Batch Processing', () => {
it('should handle multiple emails from a campaign', async () => {
const campaign = await factories.createCampaign({projectId});
const contacts = await factories.createContacts(projectId, 10);
// Create emails for all contacts
const emails = await Promise.all(
contacts.map(contact =>
factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
}),
),
);
expect(emails).toHaveLength(10);
expect(emails.every(e => e.campaignId === campaign.id)).toBe(true);
// Simulate processing all emails
await Promise.all(
emails.map(email =>
prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
}),
),
);
const processed = await prisma.email.findMany({
where: {campaignId: campaign.id},
});
expect(processed.every(e => e.status === EmailStatus.SENT)).toBe(true);
});
});
describe('Error Handling', () => {
it('should record error message on failure', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Simulate failure
const errorMessage = 'Failed to send: Rate limit exceeded';
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: errorMessage,
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe(errorMessage);
});
});
describe('Attachment Billing', () => {
it('should verify emails with attachments have attachment data', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test email with attachments</p>',
from: 'test@example.com',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: toPrismaJson([
{
filename: 'document.pdf',
content: 'base64encodedcontent',
contentType: 'application/pdf',
},
]),
},
});
// Create email without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Verify attachments are stored correctly
const emailWithAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithAttachments.id},
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
expect(emailWithAttachmentsData?.attachments).toBeDefined();
expect(Array.isArray(emailWithAttachmentsData?.attachments)).toBe(true);
expect(
Array.isArray(emailWithAttachmentsData?.attachments) ? emailWithAttachmentsData.attachments.length : 0,
).toBeGreaterThan(0);
expect(emailWithoutAttachmentsData?.attachments).toBeNull();
});
it('should verify attachment logic determines charging correctly', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test</p>',
from: 'test@example.com',
status: EmailStatus.PENDING,
sourceType: EmailSourceType.TRANSACTIONAL,
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
},
include: {
project: true,
contact: true,
},
});
// Simulate the logic from email-processor.ts
const hasAttachments =
emailWithAttachments.attachments &&
Array.isArray(emailWithAttachments.attachments) &&
emailWithAttachments.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
// Verify logic correctly identifies attachments
expect(hasAttachments).toBe(true);
expect(emailCount).toBe(2);
// Test without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
const hasNoAttachments =
emailWithoutAttachmentsData?.attachments &&
Array.isArray(emailWithoutAttachmentsData.attachments) &&
emailWithoutAttachmentsData.attachments.length > 0;
const emailCountNoAttachments = hasNoAttachments ? 2 : 1;
expect(hasNoAttachments).toBeFalsy();
expect(emailCountNoAttachments).toBe(1);
});
});
});
@@ -0,0 +1,281 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {ContactService} from '../../services/ContactService.js';
/**
* Tests for Contact Import Processor - Subscription Status Preservation
* Verifies that CSV imports preserve subscription status correctly
*/
describe('Contact Import - Subscription Status Preservation', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Existing contacts', () => {
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: 'existing-subscribed@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
// Verify subscription status unchanged
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'existing-unsubscribed@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import without subscribed column (undefined)
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
// Verify subscription status unchanged (should NOT be re-subscribed)
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
// Create an unsubscribed contact
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'import-resubscribe@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(false);
// Simulate import with explicit subscribed=true
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(true);
});
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
// Create a subscribed contact
const contact = await factories.createContact({
projectId,
subscribed: true,
email: 'import-unsubscribe@example.com',
});
// Verify initial state
expect(contact.subscribed).toBe(true);
// Simulate import with explicit subscribed=false
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
// Verify subscription status changed
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(updated?.subscribed).toBe(false);
});
});
describe('New contacts', () => {
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
const newEmail = 'new-import-default@example.com';
// Simulate import without subscribed column (undefined)
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
// New contacts should default to subscribed=true
expect(contact.subscribed).toBe(true);
});
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
const newEmail = 'new-import-explicit-sub@example.com';
// Simulate import with explicit subscribed=true
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
expect(contact.subscribed).toBe(true);
});
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
const newEmail = 'new-import-explicit-unsub@example.com';
// Simulate import with explicit subscribed=false
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
expect(contact.subscribed).toBe(false);
});
});
describe('CSV parsing logic', () => {
it('should parse "true" string as boolean true', () => {
const subscribedValue = 'true';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "1" string as boolean true', () => {
const subscribedValue = '1';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "yes" string as boolean true', () => {
const subscribedValue = 'yes';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(true);
});
it('should parse "false" string as boolean false', () => {
const subscribedValue = 'false';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "0" string as boolean false', () => {
const subscribedValue = '0';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should parse "no" string as boolean false', () => {
const subscribedValue = 'no';
const lowerValue = subscribedValue.toLowerCase().trim();
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
expect(subscribed).toBe(false);
});
it('should handle empty string as undefined', () => {
const subscribedValue = '';
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
it('should handle undefined as undefined', () => {
const subscribedValue = undefined;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
expect(subscribed).toBeUndefined();
});
});
describe('Data preservation', () => {
it('should preserve existing contact data while updating subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'preserve-data@example.com',
data: {
firstName: 'Original',
lastName: 'Name',
plan: 'pro',
},
});
// Update only subscription via import
await ContactService.upsert(projectId, contact.email, {}, true);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be updated
expect(updated?.subscribed).toBe(true);
// Original data should be preserved
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('Original');
expect(data?.lastName).toBe('Name');
expect(data?.plan).toBe('pro');
});
it('should merge new data while preserving subscription', async () => {
// Create contact with existing data
const contact = await factories.createContact({
projectId,
subscribed: false,
email: 'merge-data@example.com',
data: {
firstName: 'John',
plan: 'pro',
},
});
// Import new data without changing subscription
await ContactService.upsert(
projectId,
contact.email,
{
lastName: 'Doe',
company: 'Acme Inc',
},
undefined,
);
const updated = await prisma.contact.findUnique({
where: {id: contact.id},
});
// Subscription should be unchanged
expect(updated?.subscribed).toBe(false);
// Data should be merged
const data = updated?.data as Record<string, unknown>;
expect(data?.firstName).toBe('John'); // Preserved
expect(data?.plan).toBe('pro'); // Preserved
expect(data?.lastName).toBe('Doe'); // New
expect(data?.company).toBe('Acme Inc'); // New
});
});
});
@@ -0,0 +1,227 @@
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
import {CampaignStatus} from '@plunk/db';
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const timeControl = createTimeControl();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(() => {
// Ensure time is always restored between tests
timeControl.restore();
});
describe('Campaign Scheduling', () => {
it('should execute campaign at scheduled time', async () => {
// Freeze time at a specific moment
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 1 hour from now
const scheduledTime = timeControl.helpers.relative(1, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
name: 'Scheduled Newsletter',
subject: 'Weekly Update',
});
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
expect(campaign.scheduledFor).toEqual(scheduledTime);
// Advance time to scheduled time
timeControl.advanceTo(scheduledTime);
// Verify we're at the right time
expect(timeControl.now()).toEqual(scheduledTime);
// At this point, the scheduled processor would pick up this campaign
// and change its status to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const updatedCampaign = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should not execute campaign before scheduled time', async () => {
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 2 hours from now
const scheduledTime = timeControl.helpers.relative(2, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
// Advance time by only 1 hour (before scheduled time)
timeControl.helpers.advanceHours(1);
// Campaign should still be scheduled
const stillScheduled = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
timeControl.restore();
});
it('should handle multiple scheduled campaigns at different times', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Create campaigns scheduled at different times
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(1, 'hour'), // 11:00
{name: 'Campaign 1'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(2, 'hour'), // 12:00
{name: 'Campaign 2'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(3, 'hour'), // 13:00
{name: 'Campaign 3'},
);
// Advance to 11:00 - first campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(11);
// Advance to 12:00 - second campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(12);
// Advance to 13:00 - third campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(13);
// All campaigns should still be in scheduled state (until processor runs)
const campaigns = await prisma.campaign.findMany({
where: {projectId},
orderBy: {scheduledFor: 'asc'},
});
expect(campaigns).toHaveLength(3);
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
timeControl.restore();
});
});
describe('Campaign Status Transitions', () => {
it('should transition from SCHEDULED to SENDING', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
const scheduledTime = timeControl.helpers.relative(30, 'minute');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: scheduledTime,
});
// Advance past scheduled time
timeControl.helpers.advanceMinutes(31);
// Processor would transition to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const sending = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sending?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should eventually transition from SENDING to SENT', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENDING,
});
// Create some contacts and emails
const contact = await factories.createContact({projectId});
await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
});
// Mark campaign as sent
await prisma.campaign.update({
where: {id: campaign.id},
data: {
status: CampaignStatus.SENT,
sentAt: new Date(),
},
});
const sent = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sent?.status).toBe(CampaignStatus.SENT);
expect(sent?.sentAt).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle campaigns scheduled in the past', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign in the past
const pastTime = new Date('2025-01-19T10:00:00Z');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: pastTime,
});
// Processor should immediately pick this up
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
expect(shouldRun).toBe(true);
timeControl.restore();
});
it('should handle campaigns scheduled far in the future', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign 30 days in the future
const futureTime = timeControl.helpers.relative(30, 'day');
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
// Campaign should not be processable yet
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
expect(shouldNotRun).toBe(true);
// Advance time by 29 days - still not ready
timeControl.helpers.advanceDays(29);
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
// Advance to exactly the scheduled time
timeControl.advanceTo(futureTime);
expect(timeControl.now()).toEqual(futureTime);
timeControl.restore();
});
});
});
@@ -0,0 +1,107 @@
import type {ApiRequestCleanupJobData} from '@plunk/types';
import type {Job} from 'bullmq';
import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
/**
* API Request Cleanup Worker
* Deletes old API request logs to prevent unbounded table growth
* Runs daily to clean up logs older than 30 days (configurable)
*/
const RETENTION_DAYS = 30; // Keep logs for 30 days
const BATCH_SIZE = 10000; // Delete in batches for performance
/**
* Process API request cleanup job
*/
async function processCleanup(job: Job<ApiRequestCleanupJobData>): Promise<{deleted: number}> {
signale.info('[API-REQUEST-CLEANUP] Starting cleanup of old API request logs...');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
let totalDeleted = 0;
let hasMore = true;
try {
// Delete in batches to avoid locking the table for too long
while (hasMore) {
const result = await prisma.apiRequest.deleteMany({
where: {
createdAt: {
lt: cutoffDate,
},
},
// Note: Prisma doesn't support LIMIT in deleteMany, so we use a different approach
});
totalDeleted += result.count;
// If we deleted fewer than batch size, we're done
hasMore = result.count >= BATCH_SIZE;
if (hasMore) {
signale.info(`[API-REQUEST-CLEANUP] Deleted ${totalDeleted} records so far, continuing...`);
// Small delay between batches to reduce database load
await new Promise(resolve => setTimeout(resolve, 100));
}
}
signale.success(
`[API-REQUEST-CLEANUP] Cleanup complete. Deleted ${totalDeleted} records older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
);
// Update job progress
await job.updateProgress(100);
return {deleted: totalDeleted};
} catch (error) {
signale.error('[API-REQUEST-CLEANUP] Error during cleanup:', error);
throw error;
}
}
/**
* Create the API request cleanup worker
*/
export function createApiRequestCleanupWorker(): Worker<ApiRequestCleanupJobData> {
const redisConnection: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
...parseRedisUrl(REDIS_URL),
};
const worker = new Worker<ApiRequestCleanupJobData>('api-request-cleanup', processCleanup, {
connection: redisConnection,
concurrency: 1, // Only run one cleanup job at a time
});
worker.on('completed', job => {
signale.success(`[API-REQUEST-CLEANUP] Job ${job.id} completed:`, job.returnvalue);
});
worker.on('failed', (job, err) => {
signale.error(`[API-REQUEST-CLEANUP] Job ${job?.id} failed:`, err);
});
worker.on('error', err => {
signale.error('[API-REQUEST-CLEANUP] Worker error:', err);
});
return worker;
}
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
const urlObj = new URL(url);
return {
host: urlObj.hostname,
port: parseInt(urlObj.port || '6379', 10),
password: urlObj.password || undefined,
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
};
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Background Job: Bulk Contact Action Processor
* Processes bulk subscribe, unsubscribe, and delete operations
*/
import type {BulkContactActionJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {ContactService} from '../services/ContactService.js';
import {bulkContactQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface BulkActionResult {
operation: 'subscribe' | 'unsubscribe' | 'delete';
totalRequested: number;
successCount: number;
failureCount: number;
errors: {contactId: string; email: string; error: string}[];
}
export function createBulkContactWorker() {
const worker = new Worker<BulkContactActionJobData>(
bulkContactQueue.name,
async (job: Job<BulkContactActionJobData>) => {
const {projectId, contactIds, operation} = job.data;
signale.info(
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
);
const result: BulkActionResult = {
operation,
totalRequested: contactIds.length,
successCount: 0,
failureCount: 0,
errors: [],
};
try {
// Process contacts in batches
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
try {
let batchResult: {updated?: number; deleted?: number};
switch (operation) {
case 'subscribe':
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'unsubscribe':
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
result.successCount += batchResult.updated || 0;
break;
case 'delete':
batchResult = await ContactService.bulkDelete(projectId, batchIds);
result.successCount += batchResult.deleted || 0;
break;
}
// If some contacts in batch weren't processed, track them as failures
const processedCount = batchResult.updated || batchResult.deleted || 0;
const failedCount = batchIds.length - processedCount;
if (failedCount > 0) {
result.failureCount += failedCount;
// Note: We don't have individual contact details for batch failures
}
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
result.failureCount += batchIds.length;
result.errors.push({
contactId: 'batch',
email: '',
error: error instanceof Error ? error.message : 'Batch processing failed',
});
}
// Update progress
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
await job.updateProgress(progress);
}
signale.info(
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
);
return result;
} catch (error) {
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
throw error;
}
},
{
connection: bulkContactQueue.opts.connection,
concurrency: 3, // Process max 3 bulk operations concurrently
},
);
worker.on('completed', job => {
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
});
return worker;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Background Job: Campaign Processor
* 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 {campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>(
campaignQueue.name,
async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
},
{
connection: campaignQueue.opts.connection,
concurrency: 5, // Process up to 5 batches concurrently
},
);
worker.on('completed', job => {
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,61 @@
/**
* Domain Verification Worker
* 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 {domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js';
/**
* Process domain verification job
*/
async function processDomainVerification(job: Job<DomainVerificationJobData>): Promise<void> {
signale.info(`[DOMAIN-VERIFICATION-WORKER] Starting domain verification job ${job.id}`);
try {
await checkDomainVerifications();
signale.success(`[DOMAIN-VERIFICATION-WORKER] Completed domain verification job ${job.id}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the domain verification worker
*/
export function createDomainVerificationWorker(): Worker {
const worker = new Worker<DomainVerificationJobData>(
domainVerificationQueue.name,
async (job: Job<DomainVerificationJobData>) => {
await processDomainVerification(job);
},
{
connection: domainVerificationQueue.opts.connection,
concurrency: 1, // Process one domain verification job at a time
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire verification checks)
},
},
);
worker.on('completed', job => {
signale.success(`[DOMAIN-VERIFICATION-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[DOMAIN-VERIFICATION-WORKER] Worker error:', error);
});
return worker;
}
+225
View File
@@ -0,0 +1,225 @@
/**
* Background Job: Domain Verification Checker
* Checks domain verification status with AWS SES
*
* This is processed by BullMQ workers (see domain-verification-processor.ts)
* Scheduled to run every 5 minutes via repeatable jobs
*/
import 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';
/**
* Check verification status for all domains in the database
*/
export async function checkDomainVerifications() {
signale.info('[DOMAIN-VERIFICATION] Starting domain verification check...');
try {
const count = await prisma.domain.count();
signale.info(`[DOMAIN-VERIFICATION] Found ${count} domains to check`);
// Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({
select: {
id: true,
domain: true,
projectId: true,
verified: true,
project: {
select: {name: true},
},
},
skip: i,
take: 99,
});
if (domains.length === 0) {
continue;
}
// Get verification status from AWS SES
const sesIdentities = await getIdentities(domains.map(d => d.domain));
// Update each domain based on SES status
for (const sesIdentity of sesIdentities) {
const dbDomain = domains.find(d => d.domain === sesIdentity.domain);
if (!dbDomain) {
continue;
}
const isVerified = sesIdentity.status === 'Success';
// If domain failed verification, retry
if (sesIdentity.status === 'Failed') {
signale.warn(`[DOMAIN-VERIFICATION] Restarting verification for ${sesIdentity.domain}`);
let attempt = 0;
const maxAttempts = 5;
let success = false;
let delay = 5000;
while (attempt < maxAttempts && !success) {
try {
await verifyDomain(sesIdentity.domain);
success = true;
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
} catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string};
if (
error?.Code === 'Throttling' ||
error?.name === 'Throttling' ||
error?.message?.includes('Throttling')
) {
signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
await new Promise(r => setTimeout(r, delay));
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
);
throw e;
}
}
}
if (!success) {
signale.error(
`[DOMAIN-VERIFICATION] Failed to verify ${sesIdentity.domain} after ${maxAttempts} attempts due to throttling`,
);
}
}
// Update verification status in database
await prisma.domain.update({
where: {id: dbDomain.id},
data: {verified: isVerified},
});
// If domain was just verified, disable feedback forwarding
if (!dbDomain.verified && isVerified) {
signale.success(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is now verified!`);
try {
await disableFeedbackForwarding(sesIdentity.domain);
signale.info(`[DOMAIN-VERIFICATION] Disabled feedback forwarding for ${sesIdentity.domain}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
}
// 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));
}
// If domain was unverified, invalidate cache
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));
}
}
}
signale.success('[DOMAIN-VERIFICATION] Domain verification check completed');
} catch (error) {
signale.error('[DOMAIN-VERIFICATION] Error checking domain verifications:', error);
throw error;
}
}
/**
* Main processor function
* Call this from your scheduler/cron
*/
export async function runDomainVerificationJob() {
await checkDomainVerifications();
}
// If running this file directly (for testing or manual execution)
if (import.meta.url === `file://${process.argv[1]}`) {
signale.info('[DOMAIN-VERIFICATION] Running domain verification job manually...');
runDomainVerificationJob()
.then(() => {
signale.success('[DOMAIN-VERIFICATION] Completed successfully');
process.exit(0);
})
.catch(error => {
signale.error('[DOMAIN-VERIFICATION] Fatal error:', error);
process.exit(1);
});
}
+286
View File
@@ -0,0 +1,286 @@
/**
* Background Job: Email Processor
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
import type {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} from '../services/QueueService.js';
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
/**
* Determine the email sending rate limit (emails per second)
* Priority: ENV variable > AWS SES quota > Safe default (14)
*/
async function getEmailRateLimit(): Promise<number> {
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
// If env variable is set, use it (override)
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
return EMAIL_RATE_LIMIT_PER_SECOND;
}
// Try to fetch from AWS SES
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
const quota = await getSendingQuota();
if (quota) {
signale.info(
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
);
return quota.maxSendRate;
}
// Fallback to safe default
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
return DEFAULT_RATE_LIMIT;
}
export async function createEmailWorker() {
// Fetch the rate limit (from env, AWS, or default)
const rateLimit = await getEmailRateLimit();
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
const {emailId} = job.data;
const email = await prisma.email.findUnique({
where: {id: emailId},
include: {
contact: true,
project: true,
template: {select: {type: true}},
campaign: {select: {type: true}},
},
});
if (!email) {
throw new Error(`Email ${emailId} not found`);
}
if (email.status !== EmailStatus.PENDING) {
return;
}
// Check if project is disabled
if (email.project.disabled) {
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
return;
}
try {
// Update status to sending
await prisma.email.update({
where: {id: emailId},
data: {status: EmailStatus.SENDING},
});
// Format template variables in subject and body
const contactData = (email.contact.data as Record<string, unknown>) || {};
const formattedEmail = EmailService.format({
subject: email.subject,
body: email.body,
data: {
email: email.contact.email,
...contactData,
data: contactData,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`,
},
});
// Compile HTML with unsubscribe footer and badge
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
const compiledHtml = EmailService.compile({
content: formattedEmail.body,
contact: email.contact,
project: email.project,
includeUnsubscribe:
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
email.template?.type !== 'HEADLESS' &&
email.campaign?.type !== 'HEADLESS',
});
// Use fromName from database if available, otherwise fall back to project name
// The 'from' field in the database is just the email address
const fromName = email.fromName || email.project.name;
const fromEmail = email.from;
// 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: recipientEmail}
: recipientEmail;
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
// Send via AWS SES
const result = await sendRawEmail({
from: {
name: fromName,
email: fromEmail,
},
to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: publicHeaders,
tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
});
// Mark as sent with SES message ID
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: result.messageId,
},
});
// Record usage for billing (pay-per-email)
// Uses email ID as idempotency key to prevent double-charging on retries
// Charge 2 emails if attachments are present
if (email.project.customer) {
const hasAttachments = email.attachments && Array.isArray(email.attachments) && email.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
await MeterService.recordEmailSent(email.project.customer, emailCount, `email_${emailId}`);
}
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, 'email.sent', email.contactId, email.id, {
subject: formattedEmail.subject,
from: email.from,
fromName: email.fromName,
messageId: result.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
sentAt: new Date().toISOString(),
});
// If this email belongs to a campaign, check if all campaign emails have been sent
if (email.campaignId) {
const campaign = await prisma.campaign.findUnique({
where: {id: email.campaignId},
select: {
id: true,
name: true,
status: true,
totalRecipients: true,
projectId: true,
project: {
select: {name: true},
},
},
});
// Only check if campaign is still in SENDING status
if (campaign && campaign.status === CampaignStatus.SENDING) {
// Count how many emails have been sent for this campaign
const sentCount = await prisma.email.count({
where: {
campaignId: email.campaignId,
sentAt: {not: null},
},
});
// If all emails have been sent, mark campaign as SENT
if (sentCount >= campaign.totalRecipients) {
await prisma.campaign.update({
where: {id: email.campaignId},
data: {
status: CampaignStatus.SENT,
sentCount,
},
});
signale.success(
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
);
// Send notification about campaign send completed
const {NtfyService} = await import('../services/NtfyService.js');
await NtfyService.notifyCampaignSendCompleted(
campaign.name,
campaign.project.name,
campaign.projectId,
campaign.totalRecipients,
);
}
}
}
} catch (error) {
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: error instanceof Error ? error.message : 'Unknown error',
},
});
throw error; // Re-throw to trigger retry
}
},
{
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
duration: 1000,
},
},
);
worker.on('completed', job => {
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
});
return worker;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Background Job: Contact Import Processor
* Processes CSV contact imports with validation and batch processing
*/
import type {ContactImportJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {NtfyService} from '../services/NtfyService.js';
import {importQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface ImportResult {
totalRows: number;
successCount: number;
createdCount: number;
updatedCount: number;
failureCount: number;
errors: {row: number; email: string; error: string}[];
}
export function createImportWorker() {
const worker = new Worker<ContactImportJobData>(
importQueue.name,
async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data;
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
// Fetch project information for notifications
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
const projectName = project?.name || projectId;
const result: ImportResult = {
totalRows: 0,
successCount: 0,
createdCount: 0,
updatedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV with column header normalization
const records = parse(csvContent, {
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
skip_empty_lines: true,
trim: true,
relax_column_count: true, // Allow rows with different column counts
}) as Record<string, string>[];
result.totalRows = records.length;
// Validate row count
if (records.length === 0) {
throw new Error('CSV file is empty');
}
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
// Notify that import has started
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
// Validate that 'email' column exists (case-insensitive)
const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
throw new Error('CSV must have an "email" column (case-insensitive)');
}
// Process contacts in batches
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, Math.min(i + BATCH_SIZE, records.length));
// Process batch sequentially (to avoid overwhelming the database)
for (const [batchIndex, record] of batch.entries()) {
const rowNumber = i + batchIndex + 2; // +2 for header row and 1-based index
try {
// Validate email
const email = record.email?.trim();
if (!email) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: '',
error: 'Email is required',
});
continue;
}
// Basic email validation
if (!isValidEmail(email)) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email,
error: 'Invalid email format',
});
continue;
}
// Extract subscribed field if present (case-insensitive)
const subscribedValue = record.subscribed;
let subscribed: boolean | undefined;
if (subscribedValue !== undefined && subscribedValue !== '') {
// Handle various truthy/falsy values
const lowerValue = subscribedValue.toLowerCase().trim();
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
}
// Extract custom data (all fields except email and subscribed)
const {email: _, subscribed: __, ...customData} = record;
const data = Object.keys(customData).length > 0 ? customData : undefined;
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact;
// Upsert contact with subscribed value from CSV if provided
// For new contacts, ContactService.upsert defaults to true
// For existing contacts, only update if explicitly provided in CSV
await ContactService.upsert(projectId, email, data, subscribed);
result.successCount++;
if (isUpdate) {
result.updatedCount++;
} else {
result.createdCount++;
}
} catch (error) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: record.email || '',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
// Update progress
const progress = Math.round(((i + batch.length) / records.length) * 100);
await job.updateProgress(progress);
}
signale.info(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
);
// Notify that import has completed
await NtfyService.notifyContactImportCompleted(
projectName,
projectId,
filename,
result.successCount,
result.createdCount,
result.updatedCount,
result.failureCount,
);
return result;
} catch (error) {
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
// Notify that import has failed
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
await NtfyService.notifyContactImportFailed(projectName, projectId, filename, errorMessage);
// Return partial results with error
result.errors.push({
row: 0,
email: '',
error: errorMessage,
});
throw error; // Re-throw to mark job as failed
}
},
{
connection: importQueue.opts.connection,
concurrency: 2, // Process max 2 imports concurrently
},
);
worker.on('completed', job => {
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
});
return worker;
}
/**
* Basic email validation
*/
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Background Job: Scheduled Campaign Processor
* Processes scheduled campaigns when their time arrives
*/
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 {scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>(
scheduledQueue.name,
async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data;
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {
project: {
select: {disabled: true, id: true, name: true},
},
},
});
if (!campaign) {
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return;
}
// Check if project is disabled
if (campaign.project.disabled) {
signale.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
);
await prisma.campaign.update({
where: {id: campaignId},
data: {status: CampaignStatus.CANCELLED},
});
return;
}
// Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) {
signale.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
);
return;
}
// Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId);
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
},
{
connection: scheduledQueue.opts.connection,
concurrency: 2, // Process up to 2 scheduled campaigns concurrently
},
);
worker.on('completed', job => {
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,149 @@
/**
* Segment Count Update Worker
* 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 {segmentCountQueue} from '../services/QueueService.js';
import {SegmentService} from '../services/SegmentService.js';
/**
* Process segments for a single project
* - For segments with trackMembership: compute full membership and trigger events
* - For segments without trackMembership: only update counts
*/
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
// Get all segments for this project, separating tracked vs non-tracked
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, trackMembership: true},
});
const trackedSegments = segments.filter(s => s.trackMembership);
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
// 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 {
const result = await SegmentService.computeMembership(projectId, segment.id);
// 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);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
}
}
}
/**
* Process segment count update job
*/
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
const {projectId} = job.data;
try {
if (projectId) {
// Process specific project
await processProjectSegments(projectId);
} else {
// Process all active projects
const projects = await prisma.project.findMany({
where: {disabled: false},
select: {id: true, name: true},
});
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10;
for (let i = 0; i < projects.length; i += PROJECT_BATCH_SIZE) {
const batch = projects.slice(i, i + PROJECT_BATCH_SIZE);
await Promise.all(
batch.map(async project => {
try {
await processProjectSegments(project.id, project.name);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// Don't throw - continue with other projects
}
}),
);
// Small delay between project batches to avoid overwhelming the database
if (i + PROJECT_BATCH_SIZE < projects.length) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the segment count worker
*/
export function createSegmentCountWorker(): Worker {
const worker = new Worker<SegmentCountJobData>(
segmentCountQueue.name,
async (job: Job<SegmentCountJobData>) => {
await processSegmentCountUpdate(job);
},
{
connection: segmentCountQueue.opts.connection,
concurrency: 1, // Process one segment count job at a time to avoid database overload
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire updates)
},
},
);
worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[SEGMENT-COUNT-WORKER] Worker error:', error);
});
return worker;
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Unified Queue Worker
* Starts all queue processors (email, campaign, scheduled, workflow, import, segment-count, domain-verification)
*
* This should be run as a separate process in production:
* node dist/jobs/worker.js
*/
import {Worker} from 'bullmq';
import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createBulkContactWorker} from './bulk-contact-processor.js';
import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
import {createImportWorker} from './import-processor.js';
import {createScheduledCampaignWorker} from './scheduled-processor.js';
import {createSegmentCountWorker} from './segment-count-processor.js';
import {createWorkflowWorker} from './workflow-processor-queue.js';
const workers: {name: string; worker: Worker}[] = [];
async function startWorkers() {
signale.info('[WORKER] Starting queue workers...');
try {
// Start email worker
const emailWorker = await createEmailWorker();
workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started');
// Start campaign worker
const campaignWorker = createCampaignWorker();
workers.push({name: 'campaign', worker: campaignWorker});
signale.success('[WORKER] Campaign worker started');
// Start scheduled campaign worker
const scheduledWorker = createScheduledCampaignWorker();
workers.push({name: 'scheduled', worker: scheduledWorker});
signale.success('[WORKER] Scheduled campaign worker started');
// Start workflow worker
const workflowWorker = createWorkflowWorker();
workers.push({name: 'workflow', worker: workflowWorker});
signale.success('[WORKER] Workflow worker started');
// Start import worker
const importWorker = createImportWorker();
workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started');
// Start bulk contact action worker
const bulkContactWorker = createBulkContactWorker();
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
signale.success('[WORKER] Bulk contact action worker started');
// Start segment count worker
const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker});
signale.success('[WORKER] Segment count worker started');
// Start domain verification worker
const domainVerificationWorker = createDomainVerificationWorker();
workers.push({name: 'domain-verification', worker: domainVerificationWorker});
signale.success('[WORKER] Domain verification worker started');
// Start API request cleanup worker
const apiRequestCleanupWorker = createApiRequestCleanupWorker();
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
signale.success('[WORKER] API request cleanup worker started');
signale.success('[WORKER] All workers started successfully');
} catch (error) {
signale.error('[WORKER] Failed to start workers:', error);
process.exit(1);
}
}
async function stopWorkers() {
signale.info('[WORKER] Stopping workers...');
for (const {name, worker} of workers) {
try {
await worker.close();
signale.info(`[WORKER] ${name} worker stopped`);
} catch (error) {
signale.error(`[WORKER] Error stopping ${name} worker:`, error);
}
}
signale.success('[WORKER] All workers stopped');
process.exit(0);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
signale.info('[WORKER] Received SIGINT, shutting down gracefully...');
void stopWorkers();
});
process.on('SIGTERM', () => {
signale.info('[WORKER] Received SIGTERM, shutting down gracefully...');
void stopWorkers();
});
process.on('uncaughtException', error => {
signale.error('[WORKER] Uncaught exception:', error);
void stopWorkers();
});
process.on('unhandledRejection', (reason, promise) => {
signale.error('[WORKER] Unhandled rejection at:', promise, 'reason:', reason);
void stopWorkers();
});
// Start workers
void startWorkers();
@@ -0,0 +1,50 @@
/**
* Background Job: Workflow Queue Processor
* 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} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() {
const worker = new Worker<WorkflowStepJobData>(
workflowQueue.name,
async (job: Job<WorkflowStepJobData>) => {
const {executionId, stepId, type, stepExecutionId} = job.data;
if (type === 'timeout') {
// Handle timeout for WAIT_FOR_EVENT steps
if (!stepExecutionId) {
throw new Error('stepExecutionId is required for timeout jobs');
}
await WorkflowExecutionService.processTimeout(executionId, stepId, stepExecutionId);
} else {
// Handle regular step execution
await WorkflowExecutionService.processStepExecution(executionId, stepId);
}
},
{
connection: workflowQueue.opts.connection,
concurrency: 10, // Process up to 10 workflow steps concurrently
},
);
worker.on('completed', job => {
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,488 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import type {NextFunction, Request, Response} from 'express';
import {databaseRequestLogger} from '../requestLogger.js';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('Request Logger Middleware', () => {
const prisma = getPrismaClient();
let req: Partial<Request>;
let res: Partial<Response>;
let next: NextFunction;
let projectId: string;
let userId: string;
beforeEach(async () => {
const {user, project} = await factories.createUserWithProject();
projectId = project.id;
userId = user.id;
req = {
method: 'POST',
path: '/v1/send',
ip: '192.168.1.100',
socket: {remoteAddress: '192.168.1.100'} as Request['socket'],
get: vi.fn((header: string) => {
if (header === 'user-agent') {
return 'Mozilla/5.0 Test Browser';
}
return undefined;
}),
headers: {
'content-length': '1234',
},
};
// Mock response object with a json function that references the res object
let statusCode = 200;
res = {
locals: {
requestId: 'test-request-id-123',
auth: {
type: 'secret_key',
userId,
projectId,
},
},
get statusCode() {
return statusCode;
},
set statusCode(value: number) {
statusCode = value;
},
json: vi.fn(function (this: Response, body: unknown) {
return body;
}),
};
next = vi.fn();
});
afterEach(async () => {
vi.clearAllMocks();
// Clean up API request logs to avoid duplicate key errors
await prisma.apiRequest.deleteMany({});
});
// ========================================
// SUCCESSFUL REQUEST LOGGING
// ========================================
describe('Successful Request Logging', () => {
it('should log successful API request to database', async () => {
databaseRequestLogger(req as Request, res as Response, next);
// Middleware should call next immediately
expect(next).toHaveBeenCalled();
// Simulate response
const responseBody = {success: true, data: {id: '123'}};
await res.json!(responseBody);
// Wait for async logging to complete
await new Promise(resolve => setTimeout(resolve, 100));
// Verify database record was created
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.method).toBe('POST');
expect(loggedRequest?.path).toBe('/v1/send');
expect(loggedRequest?.statusCode).toBe(200);
expect(loggedRequest?.projectId).toBe(projectId);
expect(loggedRequest?.userId).toBe(userId);
expect(loggedRequest?.authType).toBe('secret_key');
expect(loggedRequest?.ip).toBe('192.168.1.100');
expect(loggedRequest?.userAgent).toBe('Mozilla/5.0 Test Browser');
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(0);
expect(loggedRequest?.requestSize).toBe(1234);
expect(loggedRequest?.responseSize).toBeGreaterThan(0);
});
it('should log request without auth information', async () => {
res.locals = {
requestId: 'public-request-id',
};
databaseRequestLogger(req as Request, res as Response, next);
const responseBody = {success: true};
await res.json!(responseBody);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'public-request-id'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.projectId).toBeNull();
expect(loggedRequest?.userId).toBeNull();
expect(loggedRequest?.authType).toBeNull();
});
it('should calculate request duration', async () => {
const startTime = Date.now();
databaseRequestLogger(req as Request, res as Response, next);
// Simulate some processing time
await new Promise(resolve => setTimeout(resolve, 50));
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
// Allow for timer imprecision (especially in CI environments)
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45);
expect(loggedRequest?.duration).toBeLessThan(Date.now() - startTime + 100);
});
});
// ========================================
// ERROR REQUEST LOGGING
// ========================================
describe('Error Request Logging', () => {
it('should log failed requests with error details', async () => {
res.statusCode = 400;
databaseRequestLogger(req as Request, res as Response, next);
const errorResponse = {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid email format',
},
};
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.statusCode).toBe(400);
expect(loggedRequest?.errorCode).toBe('VALIDATION_ERROR');
expect(loggedRequest?.errorMessage).toBe('Invalid email format');
});
it('should log 500 errors', async () => {
res.statusCode = 500;
databaseRequestLogger(req as Request, res as Response, next);
const errorResponse = {
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'Database connection failed',
},
};
await res.json!(errorResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(500);
expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR');
});
it('should log 404 not found errors', async () => {
res.statusCode = 404;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({
success: false,
error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'},
});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest?.statusCode).toBe(404);
expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND');
});
});
// ========================================
// SKIP LOGGING FOR EXCLUDED PATHS
// ========================================
describe('Skip Logging for Excluded Paths', () => {
it('should skip logging for health check endpoint', async () => {
req.path = '/health';
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({status: 'ok'});
await new Promise(resolve => setTimeout(resolve, 100));
// Should not create database record
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-request-id-123'},
});
expect(loggedRequest).toBeNull();
});
it('should skip logging for user session endpoints', async () => {
const sessionPaths = ['/users/@me', '/users/@me/projects', '/users/me', '/users/me/projects'];
for (const path of sessionPaths) {
req.path = path;
res.locals!.requestId = `skip-${path}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({user: 'data'});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `skip-${path}`},
});
expect(loggedRequest).toBeNull();
}
});
it('should skip logging for static assets', async () => {
const assetPaths = ['/assets/logo.png', '/assets/styles.css', '/assets/script.js'];
for (const path of assetPaths) {
req.path = path;
res.locals!.requestId = `asset-${path}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({});
await new Promise(resolve => setTimeout(resolve, 50));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `asset-${path}`},
});
expect(loggedRequest).toBeNull();
}
});
it('should skip logging for config endpoints', async () => {
const reqConfig = {...req, path: '/config'};
const resConfig = {
...res,
locals: {...res.locals!, requestId: 'test-config-skip'},
};
databaseRequestLogger(reqConfig as Request, resConfig as Response, next);
await resConfig.json!({features: {}});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-config-skip'},
});
expect(loggedRequest).toBeNull();
});
it('should LOG important API endpoints', async () => {
const importantPaths = ['/v1/send', '/v1/track', '/contacts', '/campaigns', '/templates'];
for (const path of importantPaths) {
req.path = path;
res.locals!.requestId = `log-${path.replace(/\//g, '-')}`;
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: `log-${path.replace(/\//g, '-')}`},
});
expect(loggedRequest).toBeDefined();
expect(loggedRequest?.path).toBe(path);
}
});
});
// ========================================
// CONFIGURATION & ERROR HANDLING
// ========================================
describe('Configuration & Error Handling', () => {
it('should respect REQUEST_LOGGING=false environment variable', async () => {
const originalEnv = process.env.REQUEST_LOGGING;
process.env.REQUEST_LOGGING = 'false';
// Re-import to get updated config
// Note: This test may need to be adjusted based on how your module caching works
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// Restore original value
if (originalEnv !== undefined) {
process.env.REQUEST_LOGGING = originalEnv;
} else {
delete process.env.REQUEST_LOGGING;
}
// This test might fail in current implementation since the env is read at module load time
// Consider this a documentation of desired behavior
});
it('should handle missing request ID gracefully', async () => {
res.locals = {}; // No request ID
databaseRequestLogger(req as Request, res as Response, next);
await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
// Should create a record with generated UUID
const allRequests = await prisma.apiRequest.findMany({
where: {
path: '/v1/send',
method: 'POST',
},
orderBy: {createdAt: 'desc'},
take: 1,
});
expect(allRequests.length).toBeGreaterThan(0);
expect(allRequests[0].id).toBeDefined();
});
it('should not block response if database logging fails', async () => {
// Simulate database error by using invalid data
const resInvalid = {
...res,
locals: {
requestId: null as unknown, // Invalid request ID - will cause database error
},
};
databaseRequestLogger(req as Request, resInvalid as Response, next);
// Should not throw and should call next
expect(next).toHaveBeenCalled();
// Response should still work even if logging fails
const result = resInvalid.json!({success: true});
expect(result).toEqual({success: true});
// Wait for async logging to fail silently
await new Promise(resolve => setTimeout(resolve, 100));
// The logging should have failed but not affected the response
});
});
// ========================================
// REQUEST/RESPONSE SIZE TRACKING
// ========================================
describe('Request/Response Size Tracking', () => {
it('should track request size from content-length header', async () => {
// Create a new request with different content-length
const reqWithSize = {
...req,
headers: {
'content-length': '5000',
},
};
// Create a new response with unique request ID
const resWithId = {
...res,
locals: {
...res.locals!,
requestId: 'test-size-5000',
},
};
databaseRequestLogger(reqWithSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-size-5000'},
});
expect(loggedRequest?.requestSize).toBe(5000);
});
it('should handle missing content-length header', async () => {
// Create request without content-length
const reqNoSize = {
...req,
headers: {},
};
const resWithId = {
...res,
locals: {
...res.locals!,
requestId: 'test-no-size',
},
};
databaseRequestLogger(reqNoSize as Request, resWithId as Response, next);
await resWithId.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-no-size'},
});
expect(loggedRequest?.requestSize).toBeNull();
});
it('should calculate response size from JSON body', async () => {
const jsonMock = vi.fn(function (this: Response, body: unknown) {
return body;
});
const resLarge = {
...res,
locals: {...res.locals!, requestId: 'test-large-response'},
json: jsonMock,
};
databaseRequestLogger(req as Request, resLarge as Response, next);
const largeResponse = {
success: true,
data: {
items: Array(100).fill({id: '123', name: 'Test Item', description: 'A test item'}),
},
};
await resLarge.json!(largeResponse);
await new Promise(resolve => setTimeout(resolve, 100));
const loggedRequest = await prisma.apiRequest.findUnique({
where: {id: 'test-large-response'},
});
const expectedSize = JSON.stringify(largeResponse).length;
expect(loggedRequest?.responseSize).toBe(expectedSize);
expect(loggedRequest?.responseSize).toBeGreaterThan(1000);
});
});
});
+378
View File
@@ -0,0 +1,378 @@
import dayjs from 'dayjs';
import type {NextFunction, Request, Response} from 'express';
import jsonwebtoken from 'jsonwebtoken';
import type {AuthResponse} from '@plunk/types';
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
* @param req
* @param res
* @param next
*/
export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
res.locals.auth = {type: 'jwt', userId: parseJwt(req)};
next();
};
export const jwt = {
/**
* Extracts a unsubscribe id from a jwt
* @param token The JWT token
*/
verify(token: string): string | null {
try {
const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
id: string;
};
return verified.id;
} catch {
return null;
}
},
/**
* Signs a JWT token
* @param id The user's ID to sign into a jwt token
*/
sign(id: string): string {
return jsonwebtoken.sign({id}, JWT_SECRET, {
expiresIn: '168h',
});
},
/**
* Find out when a JWT expires
* @param token The user's jwt token
*/
expires(token: string): dayjs.Dayjs {
const {exp} = jsonwebtoken.verify(token, JWT_SECRET) as {
exp?: number;
};
return dayjs(exp);
},
};
/**
* Parse a user's ID from the request JWT token
* @param request The express request object
*/
export function parseJwt(request: Request): string {
const token: string | undefined = request.cookies.next_token;
if (!token) {
throw new NotAuthenticated();
}
const id = jwt.verify(token);
if (!id) {
throw new NotAuthenticated();
}
return id;
}
/**
* Middleware to require public API key authentication (for /v1/track endpoint only)
* Validates that the request has a valid public key and sets the project
* @param req
* @param res
* @param next
*/
export const requirePublicKey = async (req: Request, res: Response, next: NextFunction) => {
try {
// Get API key from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
}
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
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 ProjectService.public(apiKey);
if (!project) {
throw new HttpException(
401,
'Invalid public API key. Ensure you are using a public key (pk_*) for this endpoint.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
};
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
ErrorCode.PROJECT_DISABLED,
);
}
}
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require secret API key authentication
* Validates that the request has a valid secret key and sets the project
* @param req
* @param res
* @param next
*/
export const requireSecretKey = async (req: Request, res: Response, next: NextFunction) => {
try {
// Get API key from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader) {
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
}
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
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 ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
401,
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
};
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
ErrorCode.PROJECT_DISABLED,
);
}
}
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require authentication - supports both JWT and secret API key
* For JWT: requires X-Project-Id header and validates user has access to the project
* For API key: only accepts secret keys (sk_*), derives project from the key
* @param req
* @param res
* @param next
*/
export const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
try {
// Check for API key first (Authorization header with Bearer token)
const authHeader = req.headers.authorization;
// If Authorization header is provided, use secret key authentication
if (authHeader) {
// Support "Bearer <key>" format
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
throw new HttpException(
401,
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
ErrorCode.MISSING_AUTH,
);
}
const apiKey = parts[1];
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 ProjectService.secret(apiKey);
if (!project) {
throw new HttpException(
401,
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
ErrorCode.INVALID_API_KEY,
);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'apiKey',
projectId: project.id,
} as AuthResponse;
// Check if project is disabled - block write operations
if (project.disabled) {
const method = req.method.toUpperCase();
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
ErrorCode.PROJECT_DISABLED,
);
}
}
return next();
}
// Otherwise, use JWT authentication
const userId = parseJwt(req);
// Get project ID from header (required for JWT auth)
const projectId = req.headers['x-project-id'] as string | undefined;
if (!projectId) {
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
}
// Verify user has access to this project and get project status
const [membership, project] = await Promise.all([
MembershipService.getMembership(userId, projectId),
ProjectService.id(projectId),
]);
if (!membership) {
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
}
// Set auth response with project ID (before disabled check so it's available for logging)
res.locals.auth = {
type: 'jwt',
userId,
projectId,
};
// Check if project is disabled - block write operations
if (project?.disabled) {
const method = req.method.toUpperCase();
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
ErrorCode.PROJECT_DISABLED,
);
}
}
next();
} catch (error) {
next(error);
}
};
/**
* Middleware to require email verification
* Must be used AFTER isAuthenticated or requireProjectAccess
* @param req
* @param res
* @param next
*/
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
try {
const auth = res.locals.auth;
if (auth.type === 'apiKey') {
return next();
}
if (!auth.userId) {
throw new NotAuthenticated();
}
const user = await UserService.id(auth.userId);
if (!user) {
throw new NotAuthenticated();
}
// If platform email verification is disabled, skip check
if (!PLUNK_ENABLED) {
return next();
}
// OAuth users are always considered verified
if (user.type !== 'PASSWORD') {
return next();
}
// PASSWORD users must verify email
if (!user.emailVerified) {
throw new HttpException(
403,
'Please verify your email address to access this resource',
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
);
}
next();
} catch (error) {
next(error);
}
};
+22
View File
@@ -0,0 +1,22 @@
import {randomUUID} from 'node:crypto';
import type {NextFunction, Request, Response} from 'express';
/**
* Middleware to add a unique request ID to each request
* The request ID is used for error tracking and debugging
*/
export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Check if request ID already exists (e.g., from load balancer)
const existingRequestId = req.headers['x-request-id'] as string | undefined;
// Generate new ID if not provided
const requestId = existingRequestId || randomUUID();
// Store in request object for use in handlers
res.locals.requestId = requestId;
// Send back in response headers for client-side tracking
res.setHeader('X-Request-ID', requestId);
next();
};
+192
View File
@@ -0,0 +1,192 @@
import type {NextFunction, Request, Response} from 'express';
import {prisma} from '../database/prisma.js';
import {logger} from '../utils/logger.js';
/**
* Check if request logging is enabled via environment variable
* Set REQUEST_LOGGING=false to disable database logging entirely
*/
const REQUEST_LOGGING_ENABLED = process.env.REQUEST_LOGGING !== 'false';
/**
* Endpoints to exclude from database logging
*
* Guidelines for what to skip:
* - Health checks and monitoring endpoints (called by load balancers every few seconds)
* - User session/auth endpoints (called on every page load in the dashboard)
* - Real-time polling endpoints (called repeatedly for live updates)
* - Static asset requests (should be served by CDN anyway)
* - Internal/admin-only endpoints that don't need audit trails
*
* What TO log (valuable for analytics/debugging):
* - Public API endpoints (/v1/send, /v1/track) - track customer usage
* - Resource CRUD operations (contacts, templates, campaigns) - audit trail
* - Authentication attempts (login, signup) - security monitoring
* - Payment/billing operations - compliance
* - Failed requests (always logged regardless of endpoint)
*/
const SKIP_LOGGING_PATHS = [
// Health/status checks (called by load balancers constantly)
'/health',
'/',
// User session endpoints (called on every dashboard page load)
'/users/@me',
'/users/@me/projects',
'/users/me',
'/users/me/projects',
// Project switching (called frequently when user switches projects)
'/users/@me/projects/:id',
// Real-time polling/stats endpoints
'/queue/stats',
// Feature flags/config (called on app initialization)
'/config',
'/config/features',
'/oauth-config',
// Field introspection (called when building forms/filters)
'/contacts/fields',
'/events/names',
];
/**
* Path patterns to exclude (regex)
* For more complex matching like "/assets/*" or "*.json"
*/
const SKIP_LOGGING_PATTERNS = [
/^\/assets\//i, // Static assets
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i, // Static files
];
/**
* Check if a request should be logged to the database
*/
function shouldLogRequest(req: Request): boolean {
const path = req.path;
// Check exact path matches
if (SKIP_LOGGING_PATHS.includes(path)) {
return false;
}
// Check pattern matches
if (SKIP_LOGGING_PATTERNS.some(pattern => pattern.test(path))) {
return false;
}
// Log everything else
return true;
}
/**
* Middleware to log API requests to the database for historical tracking and analytics
* This runs asynchronously and does not block the response
*
* IMPORTANT: Only logs important endpoints to avoid noise and reduce database load
*
* Configuration:
* - Set REQUEST_LOGGING=false to disable entirely
* - Modify SKIP_LOGGING_PATHS to exclude specific endpoints
* - Modify SKIP_LOGGING_PATTERNS to exclude path patterns
*/
export const databaseRequestLogger = (req: Request, res: Response, next: NextFunction) => {
// Check if logging is enabled
if (!REQUEST_LOGGING_ENABLED) {
return next();
}
// Skip logging for excluded endpoints
if (!shouldLogRequest(req)) {
return next();
}
const startTime = Date.now();
// Capture the original res.json to log after response
const originalJson = res.json.bind(res);
res.json = function (body: unknown) {
// Call original json method first
const result = originalJson(body);
// Log to database asynchronously (don't await, don't block response)
void logRequestToDatabase(req, res, startTime, body);
return result;
};
next();
};
/**
* Log the request to the database
* This runs asynchronously and failures are logged but don't affect the response
*/
async function logRequestToDatabase(
req: Request,
res: Response,
startTime: number,
responseBody: unknown,
): Promise<void> {
try {
const requestId = res.locals.requestId as string | undefined;
const auth = res.locals.auth as {type?: string; userId?: string; projectId?: string} | undefined;
const duration = Date.now() - startTime;
const statusCode = res.statusCode;
// Extract error information if this is an error response
let errorCode: string | undefined;
let errorMessage: string | undefined;
if (
statusCode >= 400 &&
responseBody &&
typeof responseBody === 'object' &&
'error' in responseBody &&
responseBody.error &&
typeof responseBody.error === 'object'
) {
const error = responseBody.error as {code?: string; message?: string};
errorCode = error.code;
errorMessage = error.message;
}
// Calculate request/response sizes (approximate)
const requestSize = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : undefined;
const responseSize = JSON.stringify(responseBody).length;
// Insert into database
await prisma.apiRequest.create({
data: {
id: requestId || crypto.randomUUID(), // Use request ID as primary key
method: req.method,
path: req.path,
statusCode,
duration,
projectId: auth?.projectId || null,
userId: auth?.userId || null,
authType: auth?.type || null,
ip: req.ip || req.socket.remoteAddress || null,
userAgent: req.get('user-agent') || null,
errorCode: errorCode || null,
errorMessage: errorMessage || null,
requestSize: requestSize || null,
responseSize,
},
});
// Log success for debugging (only in development)
if (process.env.NODE_ENV === 'development') {
logger.debug('Request logged to database', {requestId}, res);
}
} catch (error) {
// Log the error but don't throw - we don't want logging failures to affect the API
logger.error('Failed to log request to database', error, {
path: req.path,
method: req.method,
});
}
}
+768
View File
@@ -0,0 +1,768 @@
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 Service
*
* PERFORMANCE CONSIDERATIONS:
* - Uses cursor-based pagination for efficient large dataset handling
* - Limits date range to prevent expensive queries (default 30 days)
* - Caches statistics in Redis with 5-minute TTL
* - Uses indexed fields (createdAt) for sorting
* - Batch processes and merges results from multiple tables
*/
export class ActivityService {
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
private static readonly DEFAULT_DAYS_BACK = 30;
private static readonly STATS_CACHE_TTL = 300; // 5 minutes
/**
* Get unified activity feed for a project
*
* Performance: O(n log n) where n = limit
* - Fetches up to `limit` items from 3 tables in parallel
* - Merges and sorts by timestamp
* - Returns top `limit` items
*
* PAGINATION APPROACH:
* This implementation fetches `limit` items from each source (Events, Emails, Workflows),
* then merges and returns the top `limit` results by timestamp. This ensures a proper
* chronological timeline but has tradeoffs:
*
* Pros:
* - Proper time-based ordering across all activity types
* - Simple cursor-based pagination
* - Efficient for typical use cases
*
* Cons:
* - May fetch more items from DB than returned to client (up to 3x limit)
* - Cursor pagination across sources can miss items in rare edge cases
*
* For higher scale (10M+ activities), consider:
* - Materialized view or unified activity table
* - Event sourcing pattern with proper indexing
* - Separate pagination per activity type
*/
public static async getActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
cursor?: string,
types?: ActivityType[],
contactId?: string,
startDate?: Date,
endDate?: Date,
): Promise<CursorPaginatedResponse<Activity>> {
// Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
// Fetch extra items from each source to ensure we have enough after merging
// We fetch limit items from each, then take top limit after sorting
const fetchLimit = effectiveLimit;
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Parse cursor if provided (format: timestamp_id)
let cursorTimestamp: Date | undefined;
let cursorId: string | undefined;
if (cursor) {
const [timestamp, id] = cursor.split('_');
cursorTimestamp = timestamp ? new Date(parseInt(timestamp)) : undefined;
cursorId = id;
}
// Fetch activities from different sources in parallel
// Each source fetches up to fetchLimit items
const [events, emails, workflows] = await Promise.all([
this.fetchEvents(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchEmailActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchWorkflowActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
]);
// Merge all activities
const allActivities = [...events, ...emails, ...workflows];
// Sort by timestamp descending (most recent first)
allActivities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Take only the requested limit + 1 (to check if there are more)
const paginatedActivities = allActivities.slice(0, effectiveLimit + 1);
// Check if there are more results
const hasMore = paginatedActivities.length > effectiveLimit;
const results = hasMore ? paginatedActivities.slice(0, effectiveLimit) : paginatedActivities;
// Generate cursor from the last item
const lastActivity = results[results.length - 1];
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return {
data: results,
cursor: nextCursor,
hasMore,
};
}
/**
* Get activity statistics for a project
*
* Performance: Uses Redis cache with 5-minute TTL
* Falls back to database aggregation if cache miss
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
}
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Compute stats from database (in parallel for performance)
const [totalEvents, emailStats] = await Promise.all([
// Count total events
prisma.event.count({
where: {
projectId,
createdAt: dateFilter,
},
}),
// Aggregate email stats
prisma.email.aggregate({
where: {
projectId,
createdAt: dateFilter,
},
_count: {
id: true,
openedAt: true,
clickedAt: true,
},
}),
]);
// Count workflow executions
const totalWorkflowsStarted = await prisma.workflowExecution.count({
where: {
workflow: {
projectId,
},
startedAt: dateFilter,
},
});
const totalEmailsSent = emailStats._count.id;
const totalEmailsOpened = emailStats._count.openedAt || 0;
const totalEmailsClicked = emailStats._count.clickedAt || 0;
const stats: ActivityStats = {
totalEvents,
totalEmailsSent,
totalEmailsOpened,
totalEmailsClicked,
totalWorkflowsStarted,
openRate: totalEmailsSent > 0 ? (totalEmailsOpened / totalEmailsSent) * 100 : 0,
clickRate: totalEmailsSent > 0 ? (totalEmailsClicked / totalEmailsSent) * 100 : 0,
};
// Cache for 5 minutes
try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) {
signale.warn('[ACTIVITY] Failed to cache stats:', error);
}
return stats;
}
/**
* Invalidate activity stats cache for a project
* Should be called when new activities are created
*/
public static async invalidateStatsCache(projectId: string): Promise<void> {
try {
// Delete all cache keys for this project
const pattern = `activity:stats:${projectId}:*`;
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (error) {
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
}
}
/**
* Get recent activity count (for real-time updates)
* Returns count of activities in the last N minutes
*/
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
const since = new Date(Date.now() - minutes * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {gte: since};
const [eventCount, emailCount, workflowCount] = await Promise.all([
prisma.event.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.email.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.workflowExecution.count({
where: {
workflow: {projectId},
startedAt: dateFilter,
},
}),
]);
return eventCount + emailCount + workflowCount;
}
/**
* Get upcoming scheduled activities for a project
*
* Fetches scheduled campaigns and workflow step executions that are
* scheduled to send emails in the future.
*
* @param projectId - Project ID
* @param limit - Max number of items to return (default 50)
* @param daysAhead - How many days into the future to look (default 30)
*/
public static async getUpcomingActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
daysAhead = this.DEFAULT_DAYS_BACK,
): Promise<Activity[]> {
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
const now = new Date();
const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: now,
lte: futureDate,
};
// Fetch scheduled items in parallel
const [scheduledCampaigns, scheduledWorkflowSteps] = await Promise.all([
this.fetchScheduledCampaigns(projectId, effectiveLimit, dateFilter),
this.fetchScheduledWorkflowSteps(projectId, effectiveLimit, dateFilter),
]);
// Merge all scheduled activities
const allActivities = [...scheduledCampaigns, ...scheduledWorkflowSteps];
// Sort by timestamp ascending (earliest first for upcoming items)
allActivities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
// Return up to the limit
return allActivities.slice(0, effectiveLimit);
}
/**
* Fetch event activities
*/
private static async fetchEvents(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and event.triggered is not included
if (types && !types.includes(ActivityType.EVENT_TRIGGERED)) {
return [];
}
const where: Prisma.EventWhereInput = {
projectId,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
...(contactId ? {contactId} : {}),
};
const events = await prisma.event.findMany({
where,
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
return events.map(event => ({
id: event.id,
type: ActivityType.EVENT_TRIGGERED,
timestamp: event.createdAt,
contactEmail: event.contact?.email,
contactId: event.contactId || undefined,
metadata: {
eventName: event.name,
eventData: event.data,
},
}));
}
/**
* Fetch email activities (sent, delivered, opened, clicked, bounced)
*/
private static async fetchEmailActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
const activities: Activity[] = [];
// Determine which email statuses to fetch based on types filter
const emailTypes = types
? types.filter(t => t.startsWith('email.'))
: Object.values(ActivityType).filter(t => t.startsWith('email.'));
if (emailTypes.length === 0) {
return [];
}
const where: Prisma.EmailWhereInput = {
projectId,
...(contactId ? {contactId} : {}),
};
// 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,
OR: orConditions,
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
campaign: {
select: {
name: true,
},
},
workflowExecution: {
select: {
workflow: {
select: {
name: true,
},
},
},
},
},
});
// 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 = {
subject: email.subject,
body: email.body,
from: email.from,
fromName: email.fromName,
replyTo: email.replyTo,
toName: email.toName,
sourceType: email.sourceType,
campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name,
};
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
activities.push({
id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT,
timestamp: email.sentAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (
email.deliveredAt &&
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
isInDateRange(email.deliveredAt)
) {
activities.push({
id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED,
timestamp: email.deliveredAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
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,
timestamp: email.openedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalOpens: email.opens,
},
});
}
// Email clicked
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
activities.push({
id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED,
timestamp: email.clickedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalClicks: email.clicks,
},
});
}
// Email bounced
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
activities.push({
id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED,
timestamp: email.bouncedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
// 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;
}
/**
* Fetch workflow activities
*/
private static async fetchWorkflowActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and workflow types are not included
if (types && !types.some(t => t.startsWith('workflow.'))) {
return [];
}
const activities: Activity[] = [];
const where: Prisma.WorkflowExecutionWhereInput = {
workflow: {
projectId,
},
...(contactId ? {contactId} : {}),
};
const executions = await prisma.workflowExecution.findMany({
where: {
...where,
startedAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
},
orderBy: {startedAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
});
for (const execution of executions) {
// Workflow started
if (!types || types.includes(ActivityType.WORKFLOW_STARTED)) {
activities.push({
id: `${execution.id}_started`,
type: ActivityType.WORKFLOW_STARTED,
timestamp: execution.startedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
},
});
}
// Workflow completed
if (execution.completedAt && (!types || types.includes(ActivityType.WORKFLOW_COMPLETED))) {
activities.push({
id: `${execution.id}_completed`,
type: ActivityType.WORKFLOW_COMPLETED,
timestamp: execution.completedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
exitReason: execution.exitReason,
},
});
}
}
return activities;
}
/**
* Fetch scheduled campaigns
*/
private static async fetchScheduledCampaigns(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const campaigns = await prisma.campaign.findMany({
where: {
projectId,
status: 'SCHEDULED',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
segment: {
select: {
name: true,
},
},
},
});
return campaigns.map(campaign => ({
id: `campaign_${campaign.id}_scheduled`,
type: ActivityType.CAMPAIGN_SCHEDULED,
timestamp: campaign.scheduledFor!,
metadata: {
campaignName: campaign.name,
subject: campaign.subject,
totalRecipients: campaign.totalRecipients,
segmentName: campaign.segment?.name,
audienceType: campaign.audienceType,
},
}));
}
/**
* Fetch scheduled workflow step executions (emails with delays)
*/
private static async fetchScheduledWorkflowSteps(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {
execution: {
workflow: {
projectId,
},
},
status: 'PENDING',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
execution: {
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
},
step: {
select: {
name: true,
type: true,
config: true,
},
},
},
});
return stepExecutions
.filter(stepExec => stepExec.step.type === 'SEND_EMAIL' && stepExec.scheduledFor)
.map(stepExec => ({
id: `workflow_step_${stepExec.id}_scheduled`,
type: ActivityType.WORKFLOW_EMAIL_SCHEDULED,
timestamp: stepExec.scheduledFor!,
contactEmail: stepExec.execution.contact?.email,
contactId: stepExec.execution.contactId,
metadata: {
workflowName: stepExec.execution.workflow.name,
stepName: stepExec.step.name,
subject:
stepExec.step.config &&
typeof stepExec.step.config === 'object' &&
stepExec.step.config !== null &&
'subject' in stepExec.step.config &&
typeof (stepExec.step.config as Record<string, unknown>).subject === 'string'
? (stepExec.step.config as Record<string, unknown>).subject
: undefined,
},
}));
}
}
+426
View File
@@ -0,0 +1,426 @@
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Time series data point for analytics
*/
export interface TimeSeriesDataPoint {
date: string;
emails: number;
opens: number;
clicks: number;
bounces: number;
delivered: number;
}
/**
* Analytics Service
*
* PERFORMANCE CONSIDERATIONS:
* - Aggregates data by day to reduce row count
* - Uses Redis caching with 15-minute TTL
* - Limits date ranges to prevent expensive queries
* - Uses indexed fields (createdAt, projectId) for efficient filtering
* - For 1M+ emails, consider background jobs for pre-aggregation
*/
export class AnalyticsService {
private static readonly DEFAULT_DAYS_BACK = 30;
private static readonly MAX_DAYS_BACK = 90;
private static readonly TIMESERIES_CACHE_TTL = 900; // 15 minutes
/**
* Get time series data for email analytics
*
* Performance: O(n) where n = number of emails in date range
* - Groups emails by day using SQL aggregation
* - Cached in Redis for 15 minutes
* - Limited to 90 days max to prevent performance issues
*
* For higher scale (1M+ emails/day), consider:
* - Pre-aggregated daily stats table updated by background job
* - Materialized view with daily refresh
* - Time-series database (TimescaleDB, InfluxDB)
*/
public static async getTimeSeriesData(
projectId: string,
startDate?: Date,
endDate?: Date,
): Promise<TimeSeriesDataPoint[]> {
// Calculate date range with limits
const now = new Date();
const effectiveEndDate = endDate || now;
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
// Enforce max date range
const maxStartDate = new Date(now.getTime() - this.MAX_DAYS_BACK * 24 * 60 * 60 * 1000);
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
// Check cache first
const cacheKey = Keys.Analytics.timeseries(
projectId,
limitedStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const result = await prisma.$queryRaw<
{
date: Date;
total_emails: bigint;
total_opens: bigint;
total_clicks: bigint;
total_bounces: bigint;
total_delivered: bigint;
}[]
>`
SELECT
DATE_TRUNC('day', "createdAt") as date,
COUNT(*) as total_emails,
COUNT(CASE WHEN "openedAt" IS NOT NULL THEN 1 END) as total_opens,
COUNT(CASE WHEN "clickedAt" IS NOT NULL THEN 1 END) as total_clicks,
COUNT(CASE WHEN "bouncedAt" IS NOT NULL THEN 1 END) as total_bounces,
COUNT(CASE WHEN "deliveredAt" IS NOT NULL THEN 1 END) as total_delivered
FROM "emails"
WHERE "projectId" = ${projectId}
AND "createdAt" >= ${limitedStartDate}
AND "createdAt" <= ${effectiveEndDate}
GROUP BY DATE_TRUNC('day', "createdAt")
ORDER BY date ASC
`;
// Convert to TimeSeriesDataPoint format
const timeSeries: TimeSeriesDataPoint[] = result.map(row => ({
date: row.date.toISOString(),
emails: Number(row.total_emails),
opens: Number(row.total_opens),
clicks: Number(row.total_clicks),
bounces: Number(row.total_bounces),
delivered: Number(row.total_delivered),
}));
// Fill in missing dates with zero values
const filledTimeSeries = this.fillMissingDates(timeSeries, limitedStartDate, effectiveEndDate);
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(filledTimeSeries));
return filledTimeSeries;
}
/**
* Get campaign performance metrics
* Returns top performing campaigns by open rate
*/
public static async getTopCampaigns(
projectId: string,
limit = 10,
startDate?: Date,
endDate?: Date,
): Promise<
{
id: string;
subject: string;
sentCount: number;
openedCount: number;
clickedCount: number;
openRate: number;
clickRate: number;
}[]
> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const campaigns = await prisma.campaign.findMany({
where: {
projectId,
sentAt: {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
},
status: 'SENT',
},
select: {
id: true,
subject: true,
sentCount: true,
openedCount: true,
clickedCount: true,
},
orderBy: {
openedCount: 'desc',
},
take: limit,
});
return campaigns.map(campaign => ({
id: campaign.id,
subject: campaign.subject || 'No subject',
sentCount: campaign.sentCount || 0,
openedCount: campaign.openedCount || 0,
clickedCount: campaign.clickedCount || 0,
openRate: campaign.sentCount ? ((campaign.openedCount || 0) / campaign.sentCount) * 100 : 0,
clickRate: campaign.sentCount ? ((campaign.clickedCount || 0) / campaign.sentCount) * 100 : 0,
}));
}
/**
* Get campaign statistics overview
* Returns aggregate stats for all campaigns in date range
*
* Performance: O(1) with indexed queries
* - Uses aggregation on indexed fields
* - Cached in Redis for 15 minutes
*/
public static async getCampaignStats(
projectId: string,
startDate?: Date,
endDate?: Date,
): Promise<{
total: number;
active: number;
completed: number;
averageOpenRate: number;
averageClickRate: number;
}> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.campaignStats(
projectId,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Get all campaigns in date range
const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([
// Total campaigns created in date range
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
}),
// Active campaigns (not sent yet)
prisma.campaign.count({
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: {
in: ['DRAFT', 'SCHEDULED'],
},
},
}),
// Sent campaigns with stats
prisma.campaign.findMany({
where: {
projectId,
sentAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
status: 'SENT',
},
select: {
sentCount: true,
openedCount: true,
clickedCount: true,
},
}),
]);
// Calculate averages
let totalSent = 0;
let totalOpened = 0;
let totalClicked = 0;
sentCampaigns.forEach(campaign => {
totalSent += campaign.sentCount || 0;
totalOpened += campaign.openedCount || 0;
totalClicked += campaign.clickedCount || 0;
});
const completed = sentCampaigns.length;
const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0;
const stats = {
total: totalCampaigns,
active: activeCampaigns,
completed,
averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal
averageClickRate: Math.round(averageClickRate * 10) / 10,
};
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats));
return stats;
}
/**
* Get top events by frequency with trend data
*
* Performance: O(n log n) where n = number of unique events
* - Groups events and counts occurrences
* - Compares with previous period for trend calculation
* - Cached in Redis for 15 minutes
*/
public static async getTopEvents(
projectId: string,
limit = 5,
startDate?: Date,
endDate?: Date,
): Promise<
{
name: string;
count: number;
trend: number;
}[]
> {
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const effectiveStartDate = startDate || defaultStartDate;
const effectiveEndDate = endDate || now;
// Check cache
const cacheKey = Keys.Analytics.topEvents(
projectId,
limit,
effectiveStartDate.toISOString(),
effectiveEndDate.toISOString(),
);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Calculate previous period for trend comparison
const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime();
const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength);
const previousEndDate = new Date(effectiveStartDate.getTime());
// Get current period events
const currentEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
createdAt: {
gte: effectiveStartDate,
lte: effectiveEndDate,
},
},
_count: {
id: true,
},
orderBy: {
_count: {
id: 'desc',
},
},
take: limit,
});
// Get previous period events for trend calculation
const previousEvents = await prisma.event.groupBy({
by: ['name'],
where: {
projectId,
name: {
in: currentEvents.map(e => e.name),
},
createdAt: {
gte: previousStartDate,
lte: previousEndDate,
},
},
_count: {
id: true,
},
});
// Create map of previous counts
const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id]));
// Calculate trends
const topEvents = currentEvents.map(event => {
const currentCount = event._count.id;
const previousCount = previousCountMap.get(event.name) || 0;
// Calculate percentage change
let trend = 0;
if (previousCount > 0) {
trend = Math.round(((currentCount - previousCount) / previousCount) * 100);
} else if (currentCount > 0) {
trend = 100; // New event, 100% increase
}
return {
name: event.name,
count: currentCount,
trend,
};
});
// Cache for 15 minutes
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents));
return topEvents;
}
/**
* Fill in missing dates in time series with zero values
* Ensures consistent daily data points even when no emails were sent
*/
private static fillMissingDates(data: TimeSeriesDataPoint[], startDate: Date, endDate: Date): TimeSeriesDataPoint[] {
const result: TimeSeriesDataPoint[] = [];
const dataMap = new Map(data.map(point => [new Date(point.date).toDateString(), point]));
// Iterate through each day in range
const currentDate = new Date(startDate);
currentDate.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
while (currentDate <= end) {
const dateKey = currentDate.toDateString();
const existingData = dataMap.get(dateKey);
if (existingData) {
result.push(existingData);
} else {
// Fill with zeros for days with no data
result.push({
date: new Date(currentDate).toISOString(),
emails: 0,
opens: 0,
clicks: 0,
bounces: 0,
delivered: 0,
});
}
// Move to next day
currentDate.setDate(currentDate.getDate() + 1);
}
return result;
}
}
+37
View File
@@ -0,0 +1,37 @@
import bcrypt from 'bcrypt';
import {UserService} from './UserService.js';
export class AuthService {
public static async generateHash(password: string): Promise<string> {
return new Promise((resolve, reject) => {
void bcrypt.hash(password, 10, (err, res) => {
if (err) {
return reject(err);
}
resolve(res);
});
});
}
public static async verifyHash(password: string, hash: string) {
return new Promise((resolve, reject) => {
void bcrypt.compare(password, hash, (err, res) => {
if (err) {
return reject(err);
}
return resolve(res);
});
});
}
public static async verifyCredentials(email: string, password: string) {
const user = await UserService.email(email);
if (!user?.password) {
return false;
}
return await this.verifyHash(password, user.password);
}
}
@@ -0,0 +1,689 @@
import {EmailSourceType} from '@plunk/db';
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
/**
* Billing Limit Service
* Handles usage tracking and enforcement of billing limits per email category
*
* FREE TIER LIMITS:
* - Free tier projects (billing enabled, no subscription) have a total limit of 1000 emails/month
* - This limit is shared across all email types (workflows + campaigns + transactional)
* - Paid tier projects (with subscription) can have custom per-category limits or unlimited
*
* PERFORMANCE CONSIDERATIONS:
* - Operates at scale with 1M+ contacts/month (potentially millions of emails)
* - Uses Redis caching (5-min TTL) to avoid expensive DB queries on every email send
* - Composite index on (projectId, sourceType, createdAt) enables fast filtered counts
* - Graceful degradation: cache misses fall back to DB without blocking
* - Non-blocking: errors are logged but don't prevent email sending
*/
export class BillingLimitService {
private static readonly CACHE_TTL = 300; // 5 minutes
private static readonly WARNING_THRESHOLD = 0.8; // 80%
private static readonly FREE_TIER_TOTAL_LIMIT = 1000; // Total emails per month for free tier projects
/**
* Get total usage count across all email categories
* Used for free tier total limit enforcement
*
* @param projectId - Project ID
* @returns Total email count for the calendar month (all types combined)
*/
public static async getTotalUsage(projectId: string): Promise<number> {
const {start, end} = this.getCurrentMonthRange();
try {
const count = await prisma.email.count({
where: {
projectId,
createdAt: {
gte: start,
lt: end,
},
},
});
return count;
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to query total usage for ${projectId}:`, error);
return 0; // Return 0 on error to avoid blocking
}
}
/**
* Get current usage count for a specific email category
* Uses Redis cache with 5-minute TTL to minimize DB queries
*
* @param projectId - Project ID
* @param sourceType - Email category (TRANSACTIONAL, CAMPAIGN, WORKFLOW)
* @returns Current usage count for the calendar month
*/
public static async getUsage(projectId: string, sourceType: EmailSourceType): Promise<number> {
const cacheKey = this.getCacheKey(projectId, sourceType);
try {
// Try to get from cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return parseInt(cached, 10);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Cache read failed for ${cacheKey}:`, error);
// Continue to DB query on cache failure
}
// Cache miss - query database
const {start, end} = this.getCurrentMonthRange();
try {
const count = await prisma.email.count({
where: {
projectId,
sourceType,
createdAt: {
gte: start,
lt: end,
},
},
});
// Cache the result
try {
await redis.setex(cacheKey, this.CACHE_TTL, count.toString());
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to cache usage for ${cacheKey}:`, error);
}
return count;
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to query usage for ${projectId}/${sourceType}:`, error);
return 0; // Return 0 on error to avoid blocking
}
}
/**
* Increment usage counter in cache (called after successful email send)
* This keeps the cache accurate without requiring frequent DB queries
*
* @param projectId - Project ID
* @param sourceType - Email category
*/
public static async incrementUsage(projectId: string, sourceType: EmailSourceType): Promise<void> {
const cacheKey = this.getCacheKey(projectId, sourceType);
try {
const exists = await redis.exists(cacheKey);
if (exists) {
// Only increment if key exists (was previously cached)
await redis.incr(cacheKey);
}
// If not cached, next getUsage call will fetch from DB
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to increment usage cache for ${cacheKey}:`, error);
// Non-blocking: continue even if cache increment fails
}
}
/**
* Check if sending an email would exceed the billing limit
* Returns detailed result including warning status
*
* For free tier projects (no subscription): checks total usage across all categories against 1000/month limit
* For paid tier projects (with subscription): checks per-category limits if set
*
* @param projectId - Project ID
* @param sourceType - Email category
* @returns LimitCheckResult with allowed/warning status
*/
public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> {
try {
// Get project billing info
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {
name: true,
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
if (!project) {
signale.warn(`[BILLING_LIMIT] Project ${projectId} not found`);
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
// Determine which limit to use based on source type
let limit: number | null;
switch (sourceType) {
case EmailSourceType.WORKFLOW:
limit = project.billingLimitWorkflows;
break;
case EmailSourceType.CAMPAIGN:
limit = project.billingLimitCampaigns;
break;
case EmailSourceType.TRANSACTIONAL:
limit = project.billingLimitTransactional;
break;
case EmailSourceType.INBOUND:
limit = project.billingLimitInbound;
break;
default:
limit = null;
}
// Check if any custom per-category limits are set
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== 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
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
const totalUsage = await this.getTotalUsage(projectId);
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
const percentage = (totalUsage / freeLimit) * 100;
// Check if blocked (at or over limit)
if (totalUsage >= freeLimit) {
await NtfyService.notifyBillingLimitExceeded(
project.name,
projectId,
totalUsage,
freeLimit,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
return {
allowed: false,
warning: false,
usage: totalUsage,
limit: freeLimit,
percentage,
message: `Free tier limit reached. You've sent ${totalUsage}/${freeLimit} emails this month. Upgrade to continue sending.`,
};
}
// Check if warning (80% or more)
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
if (isWarning) {
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
totalUsage,
freeLimit,
percentage,
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
);
// Send email notification (only once per month)
await this.sendWarningEmail(
projectId,
project.name,
totalUsage,
freeLimit,
percentage,
'Free Tier (All Types)',
);
}
return {
allowed: true,
warning: isWarning,
usage: totalUsage,
limit: freeLimit,
percentage,
message: isWarning
? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${freeLimit} emails)`
: undefined,
};
}
// If no limit set for paid tier, allow unlimited
if (limit === null) {
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
// Get current usage
const usage = await this.getUsage(projectId, sourceType);
const percentage = limit > 0 ? (usage / limit) * 100 : 0;
// Check if blocked (at or over limit)
if (usage >= limit) {
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about limit exceeded
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
// Send email notification
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
}
return {
allowed: false,
warning: false,
usage,
limit,
percentage,
message: `Billing limit reached for ${sourceType.toLowerCase()} emails. Current usage: ${usage}/${limit} (${Math.round(percentage)}%)`,
};
}
// Check if warning (80% or more)
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
// Send notification for warning threshold
if (isWarning) {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
await NtfyService.notifyBillingLimitApproaching(
project.name,
projectId,
usage,
limit,
percentage,
sourceType,
);
// Send email notification (only once per month)
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
}
}
return {
allowed: true,
warning: isWarning,
usage,
limit,
percentage,
message: isWarning
? `Warning: ${sourceType.toLowerCase()} emails at ${Math.round(percentage)}% of limit (${usage}/${limit})`
: undefined,
};
} catch (error) {
signale.error(`[BILLING_LIMIT] Error checking limit for ${projectId}/${sourceType}:`, error);
// On error, allow the email to prevent service disruption
return {
allowed: true,
warning: false,
usage: 0,
limit: null,
percentage: 0,
};
}
}
/**
* Get complete billing limits and usage for all categories
* Used for displaying limits in UI
*
* For free tier projects: shows total usage across all categories with 1000/month limit
* For paid tier projects: shows per-category usage with custom limits
*
* @param projectId - Project ID
* @returns Complete billing limits and usage information
*/
public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> {
try {
// Get project info
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {
customer: true,
subscription: true,
billingLimitWorkflows: true,
billingLimitCampaigns: true,
billingLimitTransactional: true,
billingLimitInbound: true,
},
});
if (!project) {
throw new Error('Project not found');
}
// Get currency from Stripe customer if available
let currency: string | null = null;
if (stripe && project.customer) {
try {
const customer = await stripe.customers.retrieve(project.customer);
if (!customer.deleted) {
currency = customer.currency || 'usd';
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to fetch currency for customer ${project.customer}:`, error);
// Continue without currency - will be null in response
}
}
// Get usage for all categories in parallel
const [workflowUsage, campaignUsage, transactionalUsage, 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
const calculateCategoryUsage = (usage: number, limit: number | null): CategoryUsage => {
const percentage = limit !== null && limit > 0 ? (usage / limit) * 100 : 0;
return {
limit,
usage,
percentage,
isWarning: limit !== null && percentage >= this.WARNING_THRESHOLD * 100,
isBlocked: limit !== null && usage >= limit,
};
};
// Check if any custom per-category limits are set
const hasCustomLimits =
project.billingLimitWorkflows !== null ||
project.billingLimitCampaigns !== null ||
project.billingLimitTransactional !== null ||
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 + 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 four categories
// This makes it clear in the UI that it's a shared limit
const sharedUsageInfo: CategoryUsage = {
limit,
usage: totalUsage,
percentage,
isWarning,
isBlocked,
};
return {
workflows: sharedUsageInfo,
campaigns: sharedUsageInfo,
transactional: sharedUsageInfo,
inbound: sharedUsageInfo,
currency,
};
}
// Projects with subscription or custom limits: show per-category limits
return {
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
currency,
};
} catch (error) {
signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, error);
throw error;
}
}
/**
* Invalidate usage cache for a project
* Primarily used in tests to reset cache state between scenarios.
* In production, the cache naturally expires after 5 minutes.
*
* NOTE: Updating billing limits does NOT require clearing usage cache
* (use clearNotificationCacheForChangedLimits instead)
*
* @param projectId - Project ID
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const keys = [
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
this.getCacheKey(projectId, EmailSourceType.INBOUND),
];
await Promise.all(keys.map(key => redis.del(key)));
signale.debug(`[BILLING_LIMIT] Invalidated cache for project ${projectId}`);
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to invalidate cache for ${projectId}:`, error);
}
}
/**
* Clear notification cache keys for billing limits that have changed
* This allows new warning/limit emails to be sent when updated limits are reached
*
* @param projectId - Project ID
* @param oldLimits - Previous billing limits
* @param newLimits - New billing limits
*/
public static async clearNotificationCacheForChangedLimits(
projectId: string,
oldLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
inbound: number | null;
},
newLimits: {
workflows: number | null;
campaigns: number | null;
transactional: number | null;
inbound: number | null;
},
): Promise<void> {
try {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const keysToDelete: string[] = [];
// Check workflows limit
if (oldLimits.workflows !== newLimits.workflows) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
);
}
// Check campaigns limit
if (oldLimits.campaigns !== newLimits.campaigns) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
);
}
// Check transactional limit
if (oldLimits.transactional !== newLimits.transactional) {
keysToDelete.push(
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
);
}
// 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(
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
);
}
} catch (error) {
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
}
}
/**
* Get Redis cache key for usage count
*/
private static getCacheKey(projectId: string, sourceType: EmailSourceType): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return Keys.Billing.usage(projectId, sourceType, year, month);
}
/**
* Get start and end dates for current calendar month
*/
private static getCurrentMonthRange(): {start: Date; end: Date} {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
return {start, end};
}
/**
* Send billing limit warning email to project members
*/
private static async sendWarningEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
// 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 MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitWarningEmail, {
projectName,
projectId,
usage,
limit,
percentage,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
}
}
/**
* Send billing limit exceeded email to project members
*/
private static async sendLimitExceededEmail(
projectId: string,
projectName: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
try {
// Check if we've already sent this warning email this month
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
// 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 MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length === 0) {
return;
}
const template = React.createElement(BillingLimitExceededEmail, {
projectName,
projectId,
usage,
limit,
sourceType,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
} catch (error) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
}
}
}
+791
View File
@@ -0,0 +1,791 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
import {BillingLimitService} from './BillingLimitService.js';
import {DomainService} from './DomainService.js';
import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {SegmentService} from './SegmentService.js';
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
import {sendRawEmail} from './SESService.js';
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
export class CampaignService {
/**
* Create a new campaign
*/
public static async create(projectId: string, data: CreateCampaignData): Promise<Campaign> {
// Validate segment if provided
if (data.audienceType === CampaignAudienceType.SEGMENT) {
if (!data.segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
const segment = await prisma.segment.findFirst({
where: {
id: data.segmentId,
projectId,
},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
}
// Validate condition if provided
if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) {
// This will throw if condition is invalid
SegmentService.validateCondition(data.audienceCondition);
}
// Create campaign with initial recipient count of 0
const campaign = await prisma.campaign.create({
data: {
projectId,
name: data.name,
description: data.description,
subject: data.subject,
body: data.body,
from: data.from,
fromName: data.fromName,
replyTo: data.replyTo,
type: data.type ?? TemplateType.MARKETING,
audienceType: data.audienceType,
audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
},
include: {
project: {
select: {name: true},
},
},
});
// Calculate and update recipient count for the draft
const recipientCount = await this.getRecipientCount(projectId, campaign);
const updatedCampaign = await prisma.campaign.update({
where: {id: campaign.id},
data: {totalRecipients: recipientCount},
});
// Send notification about campaign creation
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
return updatedCampaign;
}
/**
* Update a campaign
*/
public static async update(projectId: string, campaignId: string, data: UpdateCampaignData): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Can only update draft or scheduled campaigns
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Cannot update campaign that is sending or has been sent');
}
// Build base update data using shared utility
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
// Handle campaign-specific fields
if (data.type !== undefined) {
updateData.type = data.type;
}
if (data.audienceType !== undefined) {
updateData.audienceType = data.audienceType;
}
if (data.audienceCondition !== undefined) {
if (data.audienceCondition) {
SegmentService.validateCondition(data.audienceCondition);
}
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
}
if (data.segmentId !== undefined) {
// Prevent changing segment on scheduled campaigns
if (campaign.status === CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Cannot change segment for scheduled campaigns');
}
if (data.segmentId) {
const segment = await prisma.segment.findFirst({
where: {id: data.segmentId, projectId},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
updateData.segment = {connect: {id: data.segmentId}};
} else {
updateData.segment = {disconnect: true};
}
// Remove segmentId from updateData to avoid conflict with relation field
delete (updateData as Record<string, unknown>).segmentId;
}
// Update the campaign first
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: updateData,
});
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
const audienceChanged =
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
return prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: recipientCount},
});
}
return updatedCampaign;
}
/**
* Get a campaign
*/
public static async get(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await prisma.campaign.findFirst({
where: {
id: campaignId,
projectId,
},
include: {
segment: true,
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
return campaign;
}
/**
* List campaigns for a project
*/
public static async list(
projectId: string,
options: {
status?: CampaignStatus;
page?: number;
pageSize?: number;
} = {},
): Promise<PaginatedResponse<Campaign>> {
const {status, page = 1, pageSize = 20} = options;
const skip = (page - 1) * pageSize;
const where: Prisma.CampaignWhereInput = {
projectId,
...(status ? {status} : {}),
};
const [campaigns, total] = await Promise.all([
prisma.campaign.findMany({
where,
include: {
segment: true,
},
orderBy: {createdAt: 'desc'},
skip,
take: pageSize,
}),
prisma.campaign.count({where}),
]);
return {
data: campaigns,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Delete a campaign
*/
public static async delete(projectId: string, campaignId: string): Promise<void> {
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId, projectId},
include: {
project: {
select: {name: true},
},
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
// Can only delete draft campaigns
if (campaign.status !== CampaignStatus.DRAFT) {
throw new HttpException(400, 'Can only delete draft campaigns');
}
await prisma.campaign.delete({
where: {id: campaignId},
});
// Send notification about campaign deletion
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
}
/**
* Duplicate a campaign
*/
public static async duplicate(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Create a new campaign with the same data but reset status and stats
const duplicatedCampaign = await prisma.campaign.create({
data: {
projectId,
name: `${campaign.name} (Copy)`,
description: campaign.description,
subject: campaign.subject,
body: campaign.body,
from: campaign.from,
fromName: campaign.fromName,
replyTo: campaign.replyTo,
type: campaign.type,
audienceType: campaign.audienceType,
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId,
status: CampaignStatus.DRAFT,
totalRecipients: 0, // Will be updated below
sentCount: 0,
deliveredCount: 0,
openedCount: 0,
clickedCount: 0,
bouncedCount: 0,
},
});
// Calculate and update recipient count
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
return prisma.campaign.update({
where: {id: duplicatedCampaign.id},
data: {totalRecipients: recipientCount},
});
}
/**
* Send campaign immediately or schedule for later
*/
public static async send(projectId: string, campaignId: string, scheduledFor?: Date): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Validate status
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
throw new HttpException(400, 'Campaign has already been sent or is currently sending');
}
// Get recipient count to validate there are contacts to send to
const recipientCount = await this.getRecipientCount(projectId, campaign);
if (recipientCount === 0) {
throw new HttpException(400, 'Campaign has no recipients');
}
// Check billing limits before scheduling/sending the campaign
// This ensures users cannot schedule campaigns that would exceed their quota
if (STRIPE_ENABLED) {
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
// If there's a limit set, verify the campaign won't exceed it
if (limitCheck.limit !== null) {
const projectedUsage = limitCheck.usage + recipientCount;
if (projectedUsage > limitCheck.limit) {
throw new HttpException(
403,
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
);
}
}
}
if (scheduledFor) {
// Schedule for later
if (scheduledFor.getTime() <= Date.now()) {
throw new HttpException(400, 'Scheduled time must be in the future');
}
// Update campaign status
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SCHEDULED,
scheduledFor,
totalRecipients: recipientCount,
},
include: {
project: {
select: {name: true},
},
},
});
// Queue for scheduled sending
await QueueService.scheduleCampaign(campaignId, scheduledFor);
// Send notification about campaign scheduled
await NtfyService.notifyCampaignScheduled(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
scheduledFor,
recipientCount,
);
return updatedCampaign;
} else {
// Send immediately - start the batch processing
await this.startSending(projectId, campaignId, recipientCount);
return this.get(projectId, campaignId);
}
}
/**
* Start sending campaign (called immediately or when scheduled time arrives)
* Now uses cursor-based pagination for better performance with large recipient lists
*/
public static async startSending(projectId: string, campaignId: string, recipientCount?: number): Promise<void> {
const campaign = await this.get(projectId, campaignId);
// Validate status
if (
campaign.status !== CampaignStatus.DRAFT &&
campaign.status !== CampaignStatus.SCHEDULED &&
campaign.status !== CampaignStatus.SENDING
) {
throw new HttpException(400, 'Campaign cannot be sent in its current status');
}
// Get recipient count if not provided
if (recipientCount === undefined) {
recipientCount = await this.getRecipientCount(projectId, campaign);
}
// Update campaign to SENDING status
const updatedCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.SENDING,
totalRecipients: recipientCount,
sentAt: new Date(),
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign sending started
await NtfyService.notifyCampaignSendingStarted(
updatedCampaign.name,
updatedCampaign.project.name,
projectId,
recipientCount,
);
// Queue first batch to start the cursor-based chain
await QueueService.queueCampaignBatch({
campaignId,
batchNumber: 1,
offset: 0,
limit: BATCH_SIZE,
});
}
/**
* Process a single batch of campaign emails
* Now uses cursor-based pagination for better performance
*/
public static async processBatch(
campaignId: string,
batchNumber: number,
offset: number,
limit: number,
cursor?: string,
): Promise<void> {
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {
project: true,
},
});
if (!campaign) {
throw new HttpException(404, 'Campaign not found');
}
if (campaign.status !== CampaignStatus.SENDING) {
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
return;
}
// Get batch of recipients using cursor-based pagination
const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor);
// Queue emails for each contact
for (const contact of contacts) {
try {
// Render template with contact data
const contactData =
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
const variables = {
id: contact.id,
email: contact.email,
...contactData,
data: contactData,
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
};
const renderedSubject = EmailService.format({
subject: campaign.subject,
body: '',
data: variables,
}).subject;
const renderedBody = EmailService.format({
subject: '',
body: campaign.body,
data: variables,
}).body;
await EmailService.sendCampaignEmail({
projectId: campaign.projectId,
contactId: contact.id,
campaignId: campaign.id,
templateId: undefined,
subject: renderedSubject,
body: renderedBody,
from: campaign.from,
fromName: campaign.fromName || undefined,
replyTo: campaign.replyTo || undefined,
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
});
} catch (error) {
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
// Continue with other contacts even if one fails
}
}
// Queue next batch if there are more contacts
if (hasMore && nextCursor) {
await QueueService.queueCampaignBatch({
campaignId,
batchNumber: batchNumber + 1,
offset: 0, // Not used with cursor pagination
limit,
cursor: nextCursor,
});
}
}
/**
* Cancel a campaign
*/
public static async cancel(projectId: string, campaignId: string): Promise<Campaign> {
const campaign = await this.get(projectId, campaignId);
// Can only cancel scheduled or sending campaigns
if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) {
throw new HttpException(400, 'Can only cancel scheduled or sending campaigns');
}
// If scheduled, remove from queue
if (campaign.status === CampaignStatus.SCHEDULED) {
await QueueService.cancelScheduledCampaign(campaignId);
}
// Update status
const cancelledCampaign = await prisma.campaign.update({
where: {id: campaignId},
data: {
status: CampaignStatus.CANCELLED,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about campaign cancellation
await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId);
return cancelledCampaign;
}
/**
* Get campaign statistics
*/
public static async getStats(projectId: string, campaignId: string) {
const campaign = await this.get(projectId, campaignId);
// Get email stats from Email table
const [sentEmails, deliveredEmails, openedEmails, clickedEmails, bouncedEmails] = await Promise.all([
prisma.email.count({
where: {campaignId, sentAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, deliveredAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, openedAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, clickedAt: {not: null}},
}),
prisma.email.count({
where: {campaignId, bouncedAt: {not: null}},
}),
]);
// Update campaign stats
await prisma.campaign.update({
where: {id: campaignId},
data: {
sentCount: sentEmails,
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
bouncedCount: bouncedEmails,
},
});
return {
totalRecipients: campaign.totalRecipients,
sentCount: sentEmails,
deliveredCount: deliveredEmails,
openedCount: openedEmails,
clickedCount: clickedEmails,
bouncedCount: bouncedEmails,
openRate: sentEmails > 0 ? (openedEmails / sentEmails) * 100 : 0,
clickRate: sentEmails > 0 ? (clickedEmails / sentEmails) * 100 : 0,
bounceRate: sentEmails > 0 ? (bouncedEmails / sentEmails) * 100 : 0,
deliveryRate: sentEmails > 0 ? (deliveredEmails / sentEmails) * 100 : 0,
};
}
/**
* Send a test email for a campaign
*/
public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise<void> {
const campaign = await this.get(projectId, campaignId);
// Validate that the test email belongs to a project member
const membership = await prisma.membership.findFirst({
where: {
projectId,
user: {
email: testEmail,
},
},
include: {
user: true,
},
});
if (!membership) {
throw new HttpException(403, 'Test emails can only be sent to project members');
}
// Verify domain is registered and verified before sending
await DomainService.verifyEmailDomain(campaign.from, projectId);
// Get project to validate from address
const project = await prisma.project.findUnique({
where: {id: projectId},
});
if (!project) {
throw new HttpException(404, 'Project not found');
}
// Prepare the email content (no variable replacement for test emails)
await sendRawEmail({
from: {
name: campaign.fromName || project.name || 'Plunk',
email: campaign.from,
},
to: [testEmail],
content: {
subject: `[TEST] ${campaign.subject}`,
html: campaign.body,
},
reply: campaign.replyTo || undefined,
headers: {
'X-Plunk-Test': 'true',
},
tracking: false, // Disable tracking for test emails
});
}
/**
* Get recipient count for a campaign
*/
private static async getRecipientCount(projectId: string, campaign: Campaign): Promise<number> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
return prisma.contact.count({where});
}
/**
* Get recipients for a campaign (legacy offset-based, kept for compatibility)
*/
private static async getRecipients(
projectId: string,
campaign: Campaign,
offset: number,
limit: number,
): Promise<Contact[]> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
return prisma.contact.findMany({
where,
skip: offset,
take: limit,
orderBy: {createdAt: 'asc'}, // Consistent ordering for batching
});
}
/**
* Get recipients for a campaign using cursor-based pagination
*/
private static async getRecipientsCursor(
projectId: string,
campaign: Campaign,
limit: number,
cursor?: string,
): Promise<{contacts: Contact[]; nextCursor?: string; hasMore: boolean}> {
const where = await this.buildRecipientWhereAsync(projectId, campaign);
// Fetch one extra to determine if there are more results
const contacts = await prisma.contact.findMany({
where,
take: limit + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? {id: cursor} : undefined,
orderBy: {id: 'asc'}, // Use ID for consistent cursor ordering
});
const hasMore = contacts.length > limit;
const results = hasMore ? contacts.slice(0, -1) : contacts;
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
return {
contacts: results,
nextCursor,
hasMore,
};
}
/**
* Build WHERE clause for campaign recipients (async for segment lookups)
*/
private static async buildRecipientWhereAsync(
projectId: string,
campaign: Campaign,
): Promise<Prisma.ContactWhereInput> {
const baseWhere: Prisma.ContactWhereInput = {
projectId,
// Transactional campaigns send to all contacts regardless of subscription status
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
};
switch (campaign.audienceType) {
case CampaignAudienceType.ALL:
return baseWhere;
case CampaignAudienceType.SEGMENT:
if (!campaign.segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
}
// Get segment and use its filters
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
case CampaignAudienceType.FILTERED: {
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
if (!condition) {
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
}
// Use the SegmentService to build the where clause from the condition
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
...baseWhere,
...segmentWhere,
};
}
default:
throw new HttpException(400, 'Invalid audience type');
}
}
/**
* Build WHERE clause for segment-based campaigns
*/
private static async buildSegmentWhereAsync(
projectId: string,
segmentId: string,
baseWhere: Prisma.ContactWhereInput,
): Promise<Prisma.ContactWhereInput> {
// Fetch the segment to get its condition
const segment = await prisma.segment.findUnique({
where: {id: segmentId},
});
if (!segment) {
throw new HttpException(404, 'Segment not found');
}
if (segment.type === 'STATIC') {
return {
...baseWhere,
segmentMemberships: {
some: {
segmentId,
exitedAt: null,
},
},
};
}
const condition = fromPrismaJson<FilterCondition>(segment.condition);
const segmentWhere = SegmentService.buildConditionClause(condition);
return {
...baseWhere,
...segmentWhere,
};
}
}
+856
View File
@@ -0,0 +1,856 @@
import {type Contact, Prisma} from '@plunk/db';
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 class ContactService {
/**
* Get all contacts for a project with cursor-based pagination
* Uses cursor pagination for better performance with large datasets
*/
public static async list(
projectId: string,
limit = 20,
cursor?: string,
search?: string,
): Promise<CursorPaginatedResponse<Contact>> {
const where: Prisma.ContactWhereInput = {
projectId,
...(search
? {
email: {
contains: search,
mode: 'insensitive' as const,
},
}
: {}),
};
// Fetch one extra to determine if there are more results
// Use composite ordering (createdAt + id) to ensure stable pagination
// This prevents skipping records when multiple contacts have the same createdAt
const contacts = await prisma.contact.findMany({
where,
take: limit + 1,
skip: cursor ? 1 : 0,
cursor: cursor ? {id: cursor} : undefined,
orderBy: [
{createdAt: 'desc'},
{id: 'desc'}, // Secondary sort by id for stable cursor pagination
],
});
const hasMore = contacts.length > limit;
const results = hasMore ? contacts.slice(0, -1) : contacts;
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
// Get total count only on first page for better performance
const total = !cursor ? await prisma.contact.count({where}) : 0;
return {
data: results,
total,
cursor: nextCursor,
hasMore,
};
}
/**
* Get a single contact by ID
*/
public static async get(projectId: string, contactId: string): Promise<Contact> {
const contact = await prisma.contact.findFirst({
where: {
id: contactId,
projectId,
},
});
if (!contact) {
throw new HttpException(404, 'Contact not found');
}
return contact;
}
/**
* Find a contact by email (returns null if not found)
*/
public static async findByEmail(projectId: string, email: string): Promise<Contact | null> {
return prisma.contact.findFirst({
where: {
projectId,
email,
},
});
}
/**
* Create a new contact
* Uses unique constraint violation to check for duplicates (more efficient)
*/
public static async create(
projectId: string,
data: {email: string; data?: Prisma.JsonValue; subscribed?: boolean},
): Promise<Contact> {
try {
return await prisma.contact.create({
data: {
projectId,
email: data.email,
data: data.data ?? Prisma.JsonNull,
subscribed: data.subscribed ?? true,
},
});
} catch (error) {
// Check if this is a unique constraint violation (P2002)
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
throw new HttpException(409, 'Contact with this email already exists in this project');
}
throw error;
}
}
/**
* Update a contact
* Uses unique constraint violation to check for duplicates (more efficient)
*/
public static async update(
projectId: string,
contactId: string,
data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean},
): Promise<Contact> {
// First verify contact exists and belongs to project
const existing = await this.get(projectId, contactId);
const updateData: Prisma.ContactUpdateInput = {};
if (data.email !== undefined) {
updateData.email = data.email;
}
if (data.data !== undefined) {
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
}
if (data.subscribed !== undefined) {
updateData.subscribed = data.subscribed;
}
// Track subscription status change
const isSubscriptionChanging = data.subscribed !== undefined && existing.subscribed !== data.subscribed;
const wasSubscribed = existing.subscribed;
try {
const updated = await prisma.contact.update({
where: {id: contactId},
data: updateData,
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (data.subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', contactId);
} else if (!data.subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', contactId);
}
}
return updated;
} catch (error) {
// Check if this is a unique constraint violation (P2002)
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
throw new HttpException(409, 'Contact with this email already exists in this project');
}
throw error;
}
}
/**
* Delete a contact
*/
public static async delete(projectId: string, contactId: string): Promise<void> {
// First verify contact exists and belongs to project
await this.get(projectId, contactId);
await prisma.contact.delete({
where: {id: contactId},
});
}
/**
* Get contact count for a project
*/
public static async count(projectId: string): Promise<number> {
return prisma.contact.count({
where: {projectId},
});
}
/**
* Upsert a contact (create or update) with metadata merging
* Supports persistent and non-persistent data fields
* Reserved fields: plunk_id, plunk_email
*/
public static async upsert(
projectId: string,
email: string,
data?: Record<string, unknown>,
subscribed?: boolean,
defaultSubscribed: boolean = true,
): Promise<Contact> {
// Find existing contact
const existing = await prisma.contact.findFirst({
where: {
projectId,
email,
},
});
// Process data to merge with existing data
let mergedData: Record<string, unknown> = {};
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
// Start with existing data
mergedData = {...existing.data};
}
// Merge new data (if provided)
if (data) {
for (const [key, value] of Object.entries(data)) {
// Skip reserved system-generated fields
// These fields are dynamically added during template rendering and cannot be overridden
const reservedFields = [
'plunk_id',
'plunk_email',
'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 (value !== null && value !== undefined && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
// Non-persistent fields are not stored in contact data
// They would be used only for the current operation (like email template rendering)
continue;
}
// Store the value
mergedData[key] = value;
}
}
if (existing) {
// Track subscription status change
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
}
}
return updated;
} else {
return prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
}
}
/**
* Get the full merged data for a contact including non-persistent fields
* This is useful for template rendering
*/
public static getMergedData(contact: Contact, temporaryData?: Record<string, unknown>): Record<string, unknown> {
const mergedData: Record<string, unknown> = {
plunk_id: contact.id,
plunk_email: contact.email,
};
// Add contact's persistent data
if (contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)) {
Object.assign(mergedData, contact.data);
}
// Explicitly expose locale as a predefined field (available in templates)
// This ensures locale is always accessible even if not in contact.data
if (mergedData.locale === undefined) {
mergedData.locale = null;
}
// Add temporary (non-persistent) data
if (temporaryData) {
for (const [key, value] of Object.entries(temporaryData)) {
// Skip reserved system-generated fields
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
if (reservedFields.includes(key)) {
continue;
}
// Handle non-persistent data format: { value: "...", persistent: false }
if (
typeof value === 'object' &&
value !== null &&
'value' in value &&
'persistent' in value &&
value.persistent === false
) {
mergedData[key] = value.value;
} else {
mergedData[key] = value;
}
}
}
return mergedData;
}
/**
* PUBLIC: Get a contact by ID (no project authentication required)
* This is used for public-facing pages like unsubscribe
*/
public static async getById(contactId: string): Promise<Contact> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
});
if (!contact) {
throw new HttpException(404, 'Contact not found');
}
return contact;
}
/**
* Get project by contact ID
* Used to fetch project settings for public endpoints
*/
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
const contact = await prisma.contact.findUnique({
where: {id: contactId},
select: {
project: {
select: {
language: true,
},
},
},
});
return contact?.project || null;
}
/**
* PUBLIC: Subscribe a contact
*/
public static async subscribe(contactId: string): Promise<Contact> {
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: true},
});
// Track subscription event
await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId);
return contact;
}
/**
* PUBLIC: Unsubscribe a contact
*/
public static async unsubscribe(contactId: string): Promise<Contact> {
const contact = await prisma.contact.update({
where: {id: contactId},
data: {subscribed: false},
});
// Track unsubscription event
await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId);
return contact;
}
/**
* Get all available contact fields for a project
* Returns both standard fields and custom fields from the data JSON column
* Now includes type information inferred from actual data
*
* @param projectId - The project ID to filter contacts
* @returns Array of field objects with name and type
*/
public static async getAvailableFields(
projectId: string,
): Promise<Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'; coverage: number}>> {
// Get total contact count for coverage calculation
const totalContacts = await prisma.contact.count({
where: {projectId},
});
// Standard fields with known types (always 100% coverage)
const standardFields = [
{field: 'email', type: 'string' as const, coverage: 100},
{field: 'subscribed', type: 'boolean' as const, coverage: 100},
{field: 'createdAt', type: 'date' as const, coverage: 100},
{field: 'updatedAt', type: 'date' as const, coverage: 100},
];
// Get custom fields from the data JSON column with type inference and coverage
// Use raw SQL to extract all keys, sample values, and contact counts from the JSON data column
const result = await prisma.$queryRaw<
Array<{key: string; sample_value: string; json_type: string; contact_count: bigint}>
>`
WITH field_keys AS (
SELECT DISTINCT jsonb_object_keys(data) as key
FROM contacts
WHERE
"projectId" = ${projectId}
AND data IS NOT NULL
AND jsonb_typeof(data) = 'object'
),
field_samples AS (
SELECT
fk.key,
jsonb_typeof(c.data->fk.key) as json_type,
(c.data->>fk.key) as sample_value
FROM field_keys fk
CROSS JOIN LATERAL (
SELECT data
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? fk.key
AND data->fk.key IS NOT NULL
LIMIT 1
) c
),
field_counts AS (
SELECT
fk.key,
COUNT(*) as contact_count
FROM field_keys fk
JOIN contacts c ON c."projectId" = ${projectId}
AND c.data ? fk.key
AND c.data->fk.key IS NOT NULL
GROUP BY fk.key
)
SELECT
fs.key,
fs.sample_value,
fs.json_type,
fc.contact_count
FROM field_samples fs
JOIN field_counts fc ON fc.key = fs.key
`;
// Infer types from JSON types and sample values, calculate coverage
const customFields = result.map(row => {
let type: 'string' | 'number' | 'boolean' | 'date' = 'string';
// PostgreSQL jsonb_typeof returns: "object", "array", "string", "number", "boolean", "null"
if (row.json_type === 'boolean') {
type = 'boolean';
} else if (row.json_type === 'number') {
type = 'number';
} else if (row.json_type === 'string' && row.sample_value) {
// Try to detect dates (ISO 8601 format)
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
if (dateRegex.test(row.sample_value)) {
type = 'date';
}
}
// Calculate coverage percentage
const contactCount = Number(row.contact_count);
const coverage = totalContacts > 0 ? Math.round((contactCount / totalContacts) * 100) : 0;
return {
field: `data.${row.key}`,
type,
coverage,
};
});
return [...standardFields, ...customFields].sort((a, b) => a.field.localeCompare(b.field));
}
/**
* Get unique values for a contact field
* Optimized for large datasets (1M+ contacts) - limits results and uses efficient queries
*
* @param projectId - The project ID to filter contacts
* @param field - The field path (e.g., "subscribed", "email", "data.plan", "data.firstName")
* @param limit - Maximum number of unique values to return (default: 100)
* @returns Array of unique values, sorted alphabetically
*/
public static async getUniqueFieldValues(
projectId: string,
field: string,
limit = 100,
): Promise<Array<string | number | boolean>> {
if (field === 'subscribed') {
// Boolean field - return both possible values
return [true, false];
}
if (field === 'email') {
// Email is not useful for dropdowns, return empty
return [];
}
// Handle JSON data fields (e.g., "data.plan" or just "plan")
const jsonField = field.startsWith('data.') ? field.substring(5) : field;
// Use raw SQL for performance with large datasets
// Extract unique values from the JSON field using PostgreSQL's JSON operators
const result = await prisma.$queryRaw<Array<{value: unknown}>>`
SELECT DISTINCT
data->>${jsonField} as value
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
AND data->>${jsonField} IS NOT NULL
AND data->>${jsonField} != ''
ORDER BY value
LIMIT ${limit}
`;
// Parse and return values, handling different data types
return result
.map(row => {
const value = String(row.value);
// Try to parse as boolean
if (value === 'true') return true;
if (value === 'false') return false;
// Try to parse as number
const numValue = Number(value);
if (!isNaN(numValue) && value.trim() !== '') {
return numValue;
}
// Return as string
return value;
})
.filter(v => v !== null && v !== undefined);
}
/**
* Check if a contact field is used in any segments or campaigns
* Returns usage information including which segments/campaigns use the field
*
* @param projectId - The project ID
* @param field - The field to check (e.g., "data.plan", "email", "subscribed")
* @returns Usage information
*/
public static async getFieldUsage(
projectId: string,
field: string,
): Promise<{
usedInSegments: Array<{id: string; name: string}>;
usedInCampaigns: Array<{id: string; name: string}>;
contactCount: number;
canDelete: boolean;
}> {
// Get all segments for the project
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, condition: true},
});
// Check which segments use this field
const usedInSegments = segments.filter(segment => {
const condition = segment.condition as FilterCondition | null;
return this.fieldUsedInCondition(field, condition);
});
// For now, we'll check if campaigns use the field in their subject or body
// This is a simplified check - you might want to enhance this based on your campaign structure
const usedInCampaigns: Array<{id: string; name: string}> = [];
// Count contacts that have this field (for data fields)
let contactCount = 0;
if (field.startsWith('data.')) {
const jsonField = field.substring(5);
const result = await prisma.$queryRaw<Array<{count: bigint}>>`
SELECT COUNT(*) as count
FROM contacts
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
AND data->${jsonField} IS NOT NULL
`;
contactCount = Number(result[0]?.count || 0);
} else if (field === 'email' || field === 'subscribed' || field === 'createdAt' || field === 'updatedAt') {
// Standard fields exist on all contacts
const result = await prisma.contact.count({where: {projectId}});
contactCount = result;
}
const canDelete = usedInSegments.length === 0 && usedInCampaigns.length === 0;
return {
usedInSegments: usedInSegments.map(s => ({id: s.id, name: s.name})),
usedInCampaigns,
contactCount,
canDelete,
};
}
/**
* Delete a custom field from all contacts
* WARNING: This is destructive and cannot be undone
* Should only be called after verifying the field is not in use
*
* @param projectId - The project ID
* @param field - The field to delete (must be a data.* field)
*/
public static async deleteField(projectId: string, field: string): Promise<{deletedFrom: number}> {
// Only allow deleting custom data fields
if (!field.startsWith('data.')) {
throw new HttpException(400, 'Can only delete custom data fields (data.*)');
}
// Check if field is in use
const usage = await this.getFieldUsage(projectId, field);
if (!usage.canDelete) {
throw new HttpException(
400,
`Cannot delete field: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInCampaigns.length} campaign(s)`,
);
}
const jsonField = field.substring(5);
// Delete the field from all contacts using raw SQL
// PostgreSQL's `-` operator removes a key from a JSON object
const result = await prisma.$executeRaw`
UPDATE contacts
SET data = data - ${jsonField}
WHERE
"projectId" = ${projectId}
AND data ? ${jsonField}
`;
return {deletedFrom: result};
}
/**
* Bulk subscribe contacts
* Updates multiple contacts to subscribed=true in batches
*/
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
// Verify all contacts belong to this project
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently unsubscribed
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
if (unsubscribedIds.length === 0) {
return {updated: 0};
}
// Update in a single query for performance
const result = await prisma.contact.updateMany({
where: {
id: {in: unsubscribedIds},
projectId,
},
data: {
subscribed: true,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk subscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk unsubscribe contacts
*/
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
const contacts = await prisma.contact.findMany({
where: {
id: {in: contactIds},
projectId,
},
select: {id: true, subscribed: true},
});
const validIds = contacts.map(c => c.id);
if (validIds.length === 0) {
return {updated: 0};
}
// Only update contacts that are currently subscribed
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
if (subscribedIds.length === 0) {
return {updated: 0};
}
const result = await prisma.contact.updateMany({
where: {
id: {in: subscribedIds},
projectId,
},
data: {
subscribed: false,
},
});
// Track events for changed contacts sequentially to avoid database deadlocks
// Process in background to avoid blocking the API response
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
// Silently ignore errors in tests due to cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
}
});
return {updated: result.count};
}
/**
* Bulk delete contacts
*/
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
const result = await prisma.contact.deleteMany({
where: {
id: {in: contactIds},
projectId,
},
});
return {deleted: result.count};
}
/**
* Helper: Check if a field is used in a filter condition (recursive)
*/
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
*
* @private
*/
private static async trackEventsSequentially(
projectId: string,
eventName: string,
contactIds: string[],
): Promise<void> {
for (const contactId of contactIds) {
try {
await EventService.trackEvent(projectId, eventName, contactId);
} catch (error) {
// Log error but continue processing remaining events
// Suppress logging in test environments to reduce noise from cleanup race conditions
if (process.env.NODE_ENV !== 'test') {
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
}
}
}
}
}
+426
View File
@@ -0,0 +1,426 @@
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, 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 {
deleteIdentity,
disableFeedbackForwarding,
getDomainVerificationAttributes,
verifyDomain,
} from './SESService.js';
export class DomainService {
/**
* Get a domain by ID
*/
public static async id(id: string) {
return wrapRedis(Keys.Domain.id(id), async () => {
return prisma.domain.findUnique({where: {id}});
});
}
/**
* Get all domains for a project
*/
public static async getProjectDomains(projectId: string) {
return wrapRedis(Keys.Domain.project(projectId), async () => {
return prisma.domain.findMany({
where: {projectId},
orderBy: {createdAt: 'desc'},
});
});
}
/**
* Add a new domain to a project and start verification
*/
public static async addDomain(projectId: string, domain: string) {
// Start verification process with AWS SES
const dkimTokens = await verifyDomain(domain);
// Create domain record
const newDomain = await prisma.domain.create({
data: {
projectId,
domain,
verified: false,
dkimTokens,
},
include: {
project: {
select: {name: true},
},
},
});
// Send notification about domain added
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
return newDomain;
}
/**
* Check verification status for a domain
*/
public static async checkVerification(domainId: string) {
const domain = await prisma.domain.findUnique({where: {id: domainId}});
if (!domain) {
throw new Error('Domain not found');
}
const attributes = await getDomainVerificationAttributes(domain.domain);
// 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({
where: {id: domainId},
data: {verified: true},
include: {
project: {
select: {name: true, id: true},
},
},
});
// 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},
data: {verified: false},
include: {
project: {
select: {name: true, id: true},
},
},
});
// Send notification about domain verification failed
await NtfyService.notifyDomainVerificationFailed(
domain.domain,
updatedDomain.project.name,
updatedDomain.project.id,
);
// 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 {
domain: domain.domain,
tokens: attributes.tokens,
status: attributes.status,
verified: attributes.status === 'Success',
};
}
/**
* Remove a domain from a project
*/
public static async removeDomain(domainId: string) {
const domain = await prisma.domain.findUnique({
where: {id: domainId},
include: {
project: {
select: {name: true, id: true},
},
},
});
if (!domain) {
throw new Error('Domain not found');
}
// Extract domain name for checking usage
const domainName = domain.domain;
// Check if domain is used in any templates
const templatesUsingDomain = await prisma.template.count({
where: {
projectId: domain.projectId,
from: {
contains: `@${domainName}`,
},
},
});
if (templatesUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${templatesUsingDomain} template(s). Update the templates first.`,
);
}
// Check if domain is used in any workflow steps (via templates)
const workflowStepsUsingDomain = await prisma.workflowStep.count({
where: {
workflow: {
projectId: domain.projectId,
},
template: {
from: {
contains: `@${domainName}`,
},
},
},
});
if (workflowStepsUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${workflowStepsUsingDomain} workflow step(s). Update the workflow templates first.`,
);
}
// Check if domain is used in any active campaigns
const campaignsUsingDomain = await prisma.campaign.count({
where: {
projectId: domain.projectId,
from: {
contains: `@${domainName}`,
},
status: {
in: ['DRAFT', 'SCHEDULED', 'SENDING'],
},
},
});
if (campaignsUsingDomain > 0) {
throw new HttpException(
409,
`Cannot delete domain: it is currently used in ${campaignsUsingDomain} active campaign(s). Update or complete the campaigns first.`,
);
}
await prisma.domain.delete({where: {id: domainId}});
// 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);
return true;
}
/**
* Get verified domains for a project
*/
public static async getVerifiedDomains(projectId: string) {
return prisma.domain.findMany({
where: {
projectId,
verified: true,
},
});
}
/**
* Verify that an email domain belongs to the specified project and is verified
* @param email Full email address (e.g., "hello@example.com")
* @param projectId Project ID to verify ownership
* @returns The verified domain object
* @throws HttpException if domain not found, not owned by project, or not verified
*/
public static async verifyEmailDomain(email: string, projectId: string) {
// Extract domain from email
const emailParts = email.split('@');
if (emailParts.length !== 2) {
throw new HttpException(400, 'Invalid email format');
}
const domainName = emailParts[1];
// Find domain in database
const domain = await prisma.domain.findFirst({
where: {
domain: domainName,
},
});
if (!domain) {
throw new HttpException(
403,
`Domain "${domainName}" is not registered. Please add and verify this domain in your project settings.`,
);
}
// Verify domain belongs to the project
if (domain.projectId !== projectId) {
throw new HttpException(
403,
`Domain "${domainName}" belongs to a different project. You cannot use this domain.`,
);
}
// Verify domain is verified
if (!domain.verified) {
throw new HttpException(
403,
`Domain "${domainName}" is not verified. Please complete the DNS verification process in your domain settings.`,
);
}
return domain;
}
/**
* Check if a domain is already linked to another project
* Used when adding a new domain to verify if the user has access to the existing project
* @param domain Domain name to check
* @param userId User ID to check membership
* @returns Object with exists flag and membership info
*/
public static async checkDomainOwnership(domain: string, userId: string) {
const existingDomain = await prisma.domain.findFirst({
where: {domain},
include: {
project: {
include: {
members: {
where: {userId},
},
},
},
},
});
if (!existingDomain) {
return {exists: false};
}
// Check if user is a member of the project that owns this domain
const isMember = existingDomain.project.members.length > 0;
return {
exists: true,
projectId: existingDomain.project.id,
projectName: existingDomain.project.name,
isMember,
};
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,265 @@
import {promises as dns} from 'dns';
import {run} from '@zootools/email-spell-checker';
import type {EmailVerificationResult} from '@plunk/types';
import {redis} from '../database/redis.js';
const DISPOSABLE_DOMAINS_URL =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
const 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
* Uses Redis for caching with 24-hour TTL
* Falls back to in-memory cache if Redis fails
*/
private static async getDisposableDomains(): Promise<Set<string>> {
// Return in-memory cache if available
if (this.disposableDomainsSet) {
return this.disposableDomainsSet;
}
try {
// Try to get from Redis cache first
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
if (cached) {
const domains = JSON.parse(cached) as string[];
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
}
// Fetch from GitHub if not in cache
const response = await fetch(DISPOSABLE_DOMAINS_URL);
if (!response.ok) {
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.disposableDomainsSet = new Set(domains);
return this.disposableDomainsSet;
} catch (error) {
console.error('Error fetching disposable domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check if a domain is disposable
*/
private static async isDisposableDomain(domain: string): Promise<boolean> {
const disposableDomains = await this.getDisposableDomains();
return disposableDomains.has(domain.toLowerCase());
}
/**
* Check if a domain is a known forwarding/alias service
*/
private static isForwardingDomain(domain: string): boolean {
return FORWARDING_DOMAINS.has(domain.toLowerCase());
}
/**
* 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;
}
try {
// 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;
}
// 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}`);
}
const text = await response.text();
const domains = text
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
// Cache in Redis
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
// Cache in memory
this.personalDomainsSet = new Set(domains);
return this.personalDomainsSet;
} catch (error) {
console.error('Error fetching personal email domains:', error);
// Return empty set as fallback - don't block email verification
return new Set<string>();
}
}
/**
* Check 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());
}
}
+536
View File
@@ -0,0 +1,536 @@
import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
/**
* Event Service
* Handles event tracking and workflow triggering
*/
export class EventService {
/**
* Track an event
* This can trigger workflows that are listening for this event
*/
public static async trackEvent(
projectId: string,
eventName: string,
contactId?: string,
emailId?: string,
data?: Record<string, unknown>,
): Promise<Event> {
// Create event record
const event = await prisma.event.create({
data: {
projectId,
contactId,
emailId,
name: eventName,
data: data ? toPrismaJson(data) : undefined,
},
});
// Trigger workflows that are listening for this event
await this.triggerWorkflows(projectId, eventName, contactId, data);
// Resume workflows waiting for this event
await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data);
return event;
}
/**
* Invalidate the workflow cache for a project
* Should be called when workflows are enabled/disabled or updated
*/
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = Keys.Workflow.enabled(projectId);
try {
await redis.del(cacheKey);
} catch (error) {
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
}
}
/**
* Get events for a contact
*/
public static async getContactEvents(projectId: string, contactId: string, limit = 50): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
contactId,
},
orderBy: {createdAt: 'desc'},
take: limit,
});
}
/**
* Get events for a project
*/
public static async getProjectEvents(projectId: string, eventName?: string, limit = 100): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
}
/**
* Get event counts by type
*/
public static async getEventStats(projectId: string, startDate?: Date, endDate?: Date) {
const where: Prisma.EventWhereInput = {
projectId,
...(startDate || endDate
? {
createdAt: {
...(startDate ? {gte: startDate} : {}),
...(endDate ? {lte: endDate} : {}),
},
}
: {}),
};
const events = await prisma.event.groupBy({
by: ['name'],
where,
_count: true,
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => ({
name: e.name,
count: e._count,
}));
}
/**
* Get unique event names for a project
*/
public static async getUniqueEventNames(projectId: string): Promise<string[]> {
const events = await prisma.event.groupBy({
by: ['name'],
where: {projectId},
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => e.name);
}
/**
* Get available event data fields for a specific event name
* Analyzes actual event data to discover which fields are present
* This is optimized for large datasets - only samples recent events
*/
public static async getAvailableEventFields(projectId: string, eventName?: string): Promise<string[]> {
// Query recent events to discover data fields (limit to 100 for performance)
const events = await prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
data: {
not: Prisma.DbNull, // Only events with data (not null)
},
},
select: {
data: true,
},
orderBy: {createdAt: 'desc'},
take: 100, // Sample recent events for performance
});
// Extract all unique keys from event data
const fieldSet = new Set<string>();
for (const event of events) {
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
const data = event.data as Record<string, unknown>;
for (const key of Object.keys(data)) {
fieldSet.add(`event.${key}`);
}
}
}
return Array.from(fieldSet).sort();
}
/**
* Check if an event is used in any segments or workflows
* Returns usage information including which segments/workflows use the event
*
* @param projectId - The project ID
* @param eventName - The event name to check (e.g., "purchase.completed", "user.signup")
* @returns Usage information
*/
public static async getEventUsage(
projectId: string,
eventName: string,
): Promise<{
usedInSegments: Array<{id: string; name: string}>;
usedInWorkflows: Array<{id: string; name: string}>;
totalCount: number;
uniqueContacts: number;
canDelete: boolean;
}> {
// Get all segments for the project
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, condition: true},
});
// Check which segments use this event
const usedInSegments = segments.filter(segment => {
const condition = segment.condition as FilterCondition | null;
return this.eventUsedInCondition(eventName, condition);
});
// Get workflows that use this event as a trigger or wait condition
const workflows = await prisma.workflow.findMany({
where: {
projectId,
OR: [
// Event as trigger
{
triggerType: 'EVENT',
triggerConfig: {
path: ['eventName'],
equals: eventName,
},
},
],
},
select: {id: true, name: true},
});
// Also check workflow steps that wait for events
const workflowStepsWithEvent = await prisma.workflowStep.findMany({
where: {
workflow: {projectId},
type: 'WAIT_FOR_EVENT',
config: {
path: ['eventName'],
equals: eventName,
},
},
include: {
workflow: {
select: {id: true, name: true},
},
},
});
const usedInWorkflows = [...workflows, ...workflowStepsWithEvent.map(step => step.workflow)].reduce(
(acc, workflow) => {
// Deduplicate by id
if (!acc.find((w: {id: string; name: string}) => w.id === workflow.id)) {
acc.push(workflow);
}
return acc;
},
[] as Array<{id: string; name: string}>,
);
// Get event statistics
const [totalCount, uniqueContacts] = await Promise.all([
prisma.event.count({
where: {projectId, name: eventName},
}),
prisma.event
.groupBy({
by: ['contactId'],
where: {projectId, name: eventName, contactId: {not: null}},
})
.then(results => results.length),
]);
const canDelete = usedInSegments.length === 0 && usedInWorkflows.length === 0;
return {
usedInSegments,
usedInWorkflows,
totalCount,
uniqueContacts,
canDelete,
};
}
/**
* Delete all events with a specific name
* WARNING: This is destructive and cannot be undone
* Should only be called after verifying the event is not in use
*
* @param projectId - The project ID
* @param eventName - The event name to delete
*/
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of reserved system events
if (this.isReservedEvent(eventName)) {
throw new Error(`Cannot delete reserved system event: ${eventName}`);
}
// Check if event is in use
const usage = await this.getEventUsage(projectId, eventName);
if (!usage.canDelete) {
throw new Error(
`Cannot delete event: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInWorkflows.length} workflow(s)`,
);
}
// Delete all events with this name
const result = await prisma.event.deleteMany({
where: {
projectId,
name: eventName,
},
});
return {deletedCount: result.count};
}
/**
* Check if an event name is reserved for system use
* Reserved patterns:
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
* - contact.subscribed, contact.unsubscribed
* - segment.*.entry, segment.*.exit
*
* @param eventName - The event name to check
* @returns true if the event is reserved, false otherwise
*/
public static isReservedEvent(eventName: string): boolean {
// Email events: email.*
if (eventName.startsWith('email.')) {
return true;
}
// Contact events: contact.subscribed, contact.unsubscribed
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
return true;
}
// Segment events: segment.*.entry, segment.*.exit
// Pattern: segment.<slug>.entry or segment.<slug>.exit
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
return true;
}
return false;
}
/**
* Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance
*/
private static async triggerWorkflows(
projectId: string,
eventName: string,
contactId?: string,
data?: Record<string, unknown>,
): Promise<void> {
// Try to get workflows from cache
const cacheKey = Keys.Workflow.enabled(projectId);
let workflows;
try {
const cached = await redis.get(cacheKey);
if (cached) {
workflows = JSON.parse(cached);
}
} catch (error) {
signale.warn('[EVENT] Failed to get workflows from cache:', error);
}
// If not in cache, fetch from database
if (!workflows) {
workflows = await prisma.workflow.findMany({
where: {
projectId,
enabled: true,
triggerType: 'EVENT',
},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
// Cache for 5 minutes
try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) {
signale.warn('[EVENT] Failed to cache workflows:', error);
}
}
for (const workflow of workflows) {
const triggerConfig = workflow.triggerConfig;
// Check if this workflow is triggered by this event
if (triggerConfig?.eventName === eventName) {
// If event is for a specific contact, start workflow for that contact
if (contactId) {
await this.startWorkflowForContact(workflow.id, contactId, data);
} else {
// If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
}
}
}
}
/**
* Start a workflow execution for a contact
*/
private static async startWorkflowForContact(
workflowId: string,
contactId: string,
context?: Record<string, unknown>,
): Promise<void> {
try {
// Get workflow with steps and configuration
const workflow = await prisma.workflow.findUnique({
where: {id: workflowId},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
if (!workflow || workflow.steps.length === 0) {
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
return;
}
// Check re-entry rules
if (!workflow.allowReentry) {
// If re-entry is not allowed, check if contact has ANY execution (regardless of status)
const existingExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
},
});
if (existingExecution) {
return;
}
} else {
// If re-entry is allowed, only check if there's a currently RUNNING execution
const runningExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
status: 'RUNNING',
},
});
if (runningExecution) {
return;
}
}
const triggerStep = workflow.steps[0];
if (!triggerStep) {
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return;
}
// Create workflow execution
const execution = await prisma.workflowExecution.create({
data: {
workflowId,
contactId,
status: 'RUNNING',
currentStepId: triggerStep.id,
context: context ? toPrismaJson(context) : undefined,
},
});
signale.info(
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
);
// Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) {
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
}
}
/**
* Helper: Check if an event is used in a filter condition (recursive)
*/
private static eventUsedInCondition(eventName: string, condition: FilterCondition | null): boolean {
if (!condition || typeof condition !== 'object') {
return false;
}
// Check groups in the condition
if (Array.isArray(condition.groups)) {
for (const group of condition.groups) {
if (this.eventUsedInGroup(eventName, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if an event is used in a filter group (recursive)
*/
private static eventUsedInGroup(eventName: string, group: FilterGroup): boolean {
if (!group || typeof group !== 'object') {
return false;
}
// Check filters in the group
if (Array.isArray(group.filters)) {
for (const filter of group.filters) {
// Event filters use field name like "event.eventName"
if (filter.field === `event.${eventName}`) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.eventUsedInCondition(eventName, group.conditions);
}
return false;
}
}
+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);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
/**
* Meter Service
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
*/
export class MeterService {
/**
* Record an email sent event to Stripe meter
* This is used for usage-based billing where customers pay per email sent
*
* @param customerId - Stripe customer ID
* @param value - Number of emails sent (default: 1)
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
*/
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
// Skip if billing is disabled (self-hosted mode)
if (!STRIPE_ENABLED || !stripe) {
return;
}
// Skip if no customer ID (not subscribed)
if (!customerId) {
return;
}
try {
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(), // Must be a string
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
} catch (error) {
// Log error but don't throw - we don't want billing issues to break email sending
signale.error('[METER] Failed to record email sent event:', error);
// If it's a rate limit error, we might want to retry
if (error instanceof Error && error.message.includes('429')) {
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
}
}
}
/**
* Record multiple emails in a single batch (for campaign sending)
* More efficient than individual calls when sending bulk emails
*
* @param customerId - Stripe customer ID
* @param count - Number of emails sent in this batch
* @param batchId - Unique identifier for this batch
*/
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
if (count <= 0) return;
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
}
/**
* Get current usage for a customer in the current billing period
* Useful for displaying usage dashboards or implementing soft limits
*
* @param customerId - Stripe customer ID
* @param meterId - The meter ID from Stripe dashboard
* @param startTime - Start of period (Unix timestamp)
* @param endTime - End of period (Unix timestamp)
*/
public static async getUsageSummary(
meterId: string,
customerId: string,
startTime: number,
endTime: number,
): Promise<unknown> {
if (!STRIPE_ENABLED || !stripe) {
return null;
}
try {
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
customer: customerId,
start_time: startTime,
end_time: endTime,
});
return summaries;
} catch (error) {
signale.error('[METER] Failed to get usage summary:', error);
return null;
}
}
}
+819
View File
@@ -0,0 +1,819 @@
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
import signale from 'signale';
/**
* Service for sending notifications via ntfy.sh
* Supports configurable ntfy server URL via NTFY_URL environment variable
*/
export class NtfyService {
private static ntfyUrl: string | null = null;
private static isConfigured = false;
/**
* Send a notification to ntfy
* @param notification - The notification details
* @returns Promise<void>
*/
public static async send(notification: NtfyNotification): Promise<void> {
this.initialize();
if (!this.ntfyUrl) {
// Silently skip if not configured
return;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'text/plain',
'Title': notification.title,
};
if (notification.priority) {
headers.Priority = notification.priority.toString();
}
if (notification.tags && notification.tags.length > 0) {
headers.Tags = notification.tags.join(',');
}
const response = await fetch(this.ntfyUrl, {
method: 'POST',
headers,
body: notification.message,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
signale.success(`[NTFY] Sent notification: ${notification.title}`);
} catch (error) {
signale.error('[NTFY] Failed to send notification:', error);
// Don't throw - notifications should not break the main flow
}
}
/**
* Send a high-priority notification
*/
public static async sendHigh(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.HIGH,
tags,
});
}
/**
* Send a max-priority urgent notification
*/
public static async sendUrgent(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.MAX,
tags,
});
}
/**
* Send a low-priority informational notification
*/
public static async sendInfo(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.LOW,
tags,
});
}
/**
* Send a default priority notification
*/
public static async sendDefault(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
await this.send({
title,
message,
priority: NtfyPriority.DEFAULT,
tags,
});
}
/**
* Notify about a new project creation
*/
public static async notifyProjectCreated(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault(
'New Project Created',
`Project "${projectName}" (${projectId}) was created by user ${userId}`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
// ===== Event-specific notification helpers =====
/**
* Notify about subscription started
*/
public static async notifySubscriptionStarted(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Started',
`Project "${projectName}" (${projectId}) started a paid subscription (${subscriptionId})`,
[NtfyTag.MONEY, NtfyTag.SUCCESS],
);
}
/**
* Notify about subscription cancelled
*/
public static async notifySubscriptionCancelled(
projectName: string,
projectId: string,
subscriptionId: string,
): Promise<void> {
await this.sendHigh(
'Subscription Cancelled',
`Project "${projectName}" (${projectId}) cancelled their subscription (${subscriptionId})`,
[NtfyTag.WARNING, NtfyTag.MONEY],
);
}
/**
* Notify about project disabled due to security risk
*/
public static async notifyProjectDisabledForSecurity(
projectName: string,
projectId: string,
violations: string[],
): Promise<void> {
const violationText = violations.join(', ');
await this.sendUrgent(
'Project Disabled - Security Risk',
`Project "${projectName}" (${projectId}) was automatically disabled due to: ${violationText}`,
[NtfyTag.SHIELD, NtfyTag.ERROR, NtfyTag.SKULL],
);
}
/**
* Notify about payment failure
*/
public static async notifyPaymentFailed(projectName: string, projectId: string): Promise<void> {
await this.sendHigh('Payment Failed', `Payment failed for project "${projectName}" (${projectId})`, [
NtfyTag.WARNING,
NtfyTag.MONEY,
]);
}
/**
* Notify about project disabled due to payment failure
*/
public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise<void> {
await this.sendUrgent(
'Project Disabled - Payment Failed',
`Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR],
);
}
/**
* Notify about successful invoice payment - MIN priority (routine)
*/
public static async notifyInvoicePaid(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Invoice Paid',
message: `Invoice paid for project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.MONEY, NtfyTag.SUCCESS],
});
}
/**
* Notify about subscription update - LOW priority (minor changes)
*/
public static async notifySubscriptionUpdated(projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Subscription Updated',
message: `Subscription updated for project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.MONEY, NtfyTag.INFO],
});
}
/**
* Notify about project deletion - DEFAULT priority
*/
public static async notifyProjectDeleted(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendDefault('Project Deleted', `Project "${projectName}" (${projectId}) was deleted by user ${userId}`, [
NtfyTag.WARNING,
]);
}
/**
* Notify about security warning (non-critical)
*/
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
// 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',
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about email bounce - LOW priority (high volume)
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
*/
public static async notifyEmailBounce(
projectName: string,
projectId: string,
recipientEmail: string,
bounceType?: string,
): Promise<void> {
// Import redis at runtime to avoid circular dependencies
const {redis} = await import('../database/redis.js');
// Use Redis counters to track bounce count globally
const globalCountKey = `ntfy:bounce:count`;
const bounceListKey = `ntfy:bounce:latest`;
// Increment global counter
const globalCount = await redis.incr(globalCountKey);
// 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(bounceListKey, 86400);
}
// Only send notification every 20 bounces
if (globalCount % 20 === 0) {
// Get the latest 20 bounces
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
// 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');
await this.send({
title: 'Email Bounces',
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
}
/**
* Notify about email complaint - HIGH priority (reputation risk)
*/
public static async notifyEmailComplaint(
projectName: string,
projectId: string,
recipientEmail: string,
): Promise<void> {
await this.sendHigh(
'Email Complaint',
`Email complaint received from ${recipientEmail} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.ERROR],
);
}
/**
* Notify about new user account created via signup - LOW priority
*/
public static async notifyUserSignup(userEmail: string, userId: string): Promise<void> {
await this.send({
title: 'New User Signup',
message: `New user registered: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
/**
* Notify about 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
*/
public static async notifyUserOAuthSignup(
userEmail: string,
userId: string,
provider: 'GitHub' | 'Google',
): Promise<void> {
await this.send({
title: `New User - ${provider} OAuth`,
message: `New user registered via ${provider}: ${userEmail} (${userId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
});
}
/**
* Notify about campaign created (draft) - LOW priority
*/
public static async notifyCampaignCreated(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Created',
message: `Campaign "${campaignName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
// ===== Campaign event notifications =====
/**
* Notify about campaign scheduled - DEFAULT priority
*/
public static async notifyCampaignScheduled(
campaignName: string,
projectName: string,
projectId: string,
scheduledFor: Date,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Scheduled',
`Campaign "${campaignName}" scheduled to send to ${recipientCount} recipients at ${scheduledFor.toISOString()} in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.INFO],
);
}
/**
* Notify about campaign sending started - LOW priority (informational)
*/
public static async notifyCampaignSendingStarted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.send({
title: 'Campaign Sending Started',
message: `Campaign "${campaignName}" started sending to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET, NtfyTag.CHART],
});
}
/**
* Notify about campaign send completed - DEFAULT priority
*/
public static async notifyCampaignSendCompleted(
campaignName: string,
projectName: string,
projectId: string,
recipientCount: number,
): Promise<void> {
await this.sendDefault(
'Campaign Send Completed',
`Campaign "${campaignName}" successfully sent to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
);
}
/**
* Notify about campaign cancelled - LOW priority
*/
public static async notifyCampaignCancelled(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Cancelled',
message: `Campaign "${campaignName}" was cancelled in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.WARNING],
});
}
/**
* Notify about campaign deleted - LOW priority
*/
public static async notifyCampaignDeleted(
campaignName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Campaign Deleted',
message: `Campaign "${campaignName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about workflow created - LOW priority
*/
public static async notifyWorkflowCreated(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Created',
message: `Workflow "${workflowName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.ROCKET],
});
}
// ===== Workflow event notifications =====
/**
* Notify about workflow enabled
*/
public static async notifyWorkflowEnabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Enabled',
`Workflow "${workflowName}" is now active in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS],
);
}
/**
* Notify about workflow disabled
*/
public static async notifyWorkflowDisabled(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendDefault(
'Workflow Disabled',
`Workflow "${workflowName}" has been disabled in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING],
);
}
/**
* Notify about workflow deleted - LOW priority
*/
public static async notifyWorkflowDeleted(
workflowName: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.send({
title: 'Workflow Deleted',
message: `Workflow "${workflowName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.LOW,
tags: [NtfyTag.INFO],
});
}
/**
* Notify about workflow execution failed
*/
public static async notifyWorkflowExecutionFailed(
workflowName: string,
projectName: string,
projectId: string,
contactEmail: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Workflow Execution Failed',
`Workflow "${workflowName}" failed for contact ${contactEmail} in project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR, NtfyTag.WARNING],
);
}
/**
* Notify about domain added
*/
public static async notifyDomainAdded(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Added',
`Domain "${domain}" added for verification in project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
// ===== Domain verification notifications =====
/**
* Notify about domain verified
*/
public static async notifyDomainVerified(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendDefault(
'Domain Verified',
`Domain "${domain}" is now verified and ready for use in project "${projectName}" (${projectId})`,
[NtfyTag.SUCCESS, NtfyTag.SHIELD],
);
}
/**
* Notify about domain verification failed
*/
public static async notifyDomainVerificationFailed(
domain: string,
projectName: string,
projectId: string,
): Promise<void> {
await this.sendHigh(
'Domain Verification Failed',
`Domain "${domain}" failed verification in project "${projectName}" (${projectId}). Email sending may be affected.`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
}
/**
* Notify about domain removed
*/
public static async notifyDomainRemoved(domain: string, projectName: string, projectId: string): Promise<void> {
await this.sendInfo(
'Domain Removed',
`Domain "${domain}" was removed from project "${projectName}" (${projectId})`,
[NtfyTag.INFO],
);
}
/**
* Notify about billing limit approaching (80% threshold)
*/
public static async notifyBillingLimitApproaching(
projectName: string,
projectId: string,
usage: number,
limit: number,
percentage: number,
sourceType: string,
): Promise<void> {
// 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})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
);
}
// ===== Billing and usage limit notifications =====
/**
* Notify about billing limit exceeded - MAX priority (blocks operations)
*/
public static async notifyBillingLimitExceeded(
projectName: string,
projectId: string,
usage: number,
limit: number,
sourceType: string,
): Promise<void> {
// 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.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
);
}
/**
* Notify about API keys regenerated
*/
public static async notifyApiKeysRegenerated(projectName: string, projectId: string, userId: string): Promise<void> {
await this.sendHigh(
'API Keys Regenerated',
`API keys for project "${projectName}" (${projectId}) were regenerated by user ${userId}`,
[NtfyTag.SHIELD, NtfyTag.WARNING],
);
}
// ===== API key notifications =====
/**
* Notify about contact import started - MIN priority (routine, high volume)
*/
public static async notifyContactImportStarted(
projectName: string,
projectId: string,
filename: string,
totalRows: number,
): Promise<void> {
await this.send({
title: 'Contact Import Started',
message: `Importing ${totalRows} contacts from "${filename}" into project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.ROCKET],
});
}
// ===== Contact import notifications =====
/**
* Notify about contact import completed
*/
public static async notifyContactImportCompleted(
projectName: string,
projectId: string,
filename: string,
successCount: number,
createdCount: number,
updatedCount: number,
failureCount: number,
): Promise<void> {
await this.sendDefault(
'Contact Import Completed',
`Import "${filename}" completed for project "${projectName}" (${projectId}). Success: ${successCount} (${createdCount} new, ${updatedCount} updated), Errors: ${failureCount}`,
[NtfyTag.SUCCESS, NtfyTag.CHART],
);
}
/**
* Notify about contact import failed
*/
public static async notifyContactImportFailed(
projectName: string,
projectId: string,
filename: string,
error: string,
): Promise<void> {
await this.sendHigh(
'Contact Import Failed',
`Contact import "${filename}" failed for project "${projectName}" (${projectId}). Error: ${error}`,
[NtfyTag.ERROR],
);
}
/**
* Notify about segment created - MIN priority
*/
public static async notifySegmentCreated(segmentName: string, projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Segment Created',
message: `Segment "${segmentName}" created in project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
// ===== Segment notifications =====
/**
* Notify about segment membership computed - LOW priority
*/
public static async notifySegmentMembershipComputed(
segmentName: string,
projectName: string,
projectId: string,
memberCount: number,
added?: number,
removed?: number,
): Promise<void> {
let message = `Segment "${segmentName}" in project "${projectName}" (${projectId}) now has ${memberCount} members`;
if (added !== undefined && removed !== undefined) {
const changes: string[] = [];
if (added > 0) changes.push(`+${added} added`);
if (removed > 0) changes.push(`-${removed} removed`);
if (changes.length > 0) {
message += ` (${changes.join(', ')})`;
}
}
await this.send({
title: 'Segment Membership Updated',
message,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/**
* Notify about 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
*/
public static async notifySegmentDeleted(segmentName: string, projectName: string, projectId: string): Promise<void> {
await this.send({
title: 'Segment Deleted',
message: `Segment "${segmentName}" was deleted from project "${projectName}" (${projectId})`,
priority: NtfyPriority.MIN,
tags: [NtfyTag.INFO],
});
}
/**
* Initialize the ntfy service with the configured URL
*/
private static initialize(): void {
if (this.isConfigured) {
return;
}
this.ntfyUrl = process.env.NTFY_URL || null;
this.isConfigured = true;
if (!this.ntfyUrl) {
signale.warn('[NTFY] NTFY_URL not configured - notifications will be skipped');
} else {
signale.info(`[NTFY] Initialized with URL: ${this.ntfyUrl}`);
}
}
}
+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,
},
});
});
}
}
+562
View File
@@ -0,0 +1,562 @@
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 Configuration
*/
const redisConnection: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
// Parse Redis URL
...parseRedisUrl(REDIS_URL),
};
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
const urlObj = new URL(url);
return {
host: urlObj.hostname,
port: parseInt(urlObj.port || '6379', 10),
password: urlObj.password || undefined,
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
};
}
/**
* Queue Instances
*/
export const emailQueue = new Queue<SendEmailJobData>('email', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 1000, // Keep last 1000 completed jobs
removeOnFail: 5000, // Keep last 5000 failed jobs
},
});
export const campaignQueue = new Queue<CampaignBatchJobData>('campaign', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
});
export const workflowQueue = new Queue<WorkflowStepJobData>('workflow', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: 1000,
removeOnFail: 5000,
},
});
export const scheduledQueue = new Queue<ScheduledCampaignJobData>('scheduled', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 100,
removeOnFail: 500,
},
});
export const importQueue = new Queue<ContactImportJobData>('import', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for imports
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed imports
removeOnFail: 100, // Keep last 100 failed imports
},
});
export const segmentCountQueue = new Queue<SegmentCountJobData>('segment-count', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 10, // Keep last 10 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
},
});
export const domainVerificationQueue = new Queue<DomainVerificationJobData>('domain-verification', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 10000,
},
removeOnComplete: 10, // Keep last 10 completed jobs
removeOnFail: 50, // Keep last 50 failed jobs
},
});
export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-request-cleanup', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2,
backoff: {
type: 'exponential',
delay: 30000,
},
removeOnComplete: 5, // Keep last 5 completed jobs
removeOnFail: 20, // Keep last 20 failed jobs
},
});
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
connection: redisConnection,
defaultJobOptions: {
attempts: 2, // Limited retries for bulk operations
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 50, // Keep last 50 completed bulk operations
removeOnFail: 100, // Keep last 100 failed bulk operations
},
});
/**
* Queue Service - Centralized queue management
*/
export class QueueService {
/**
* Add email to queue for sending
*/
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
return emailQueue.add(
'send-email',
{emailId},
{
delay, // Optional delay in milliseconds
jobId: `email-${emailId}`, // Prevent duplicate jobs
},
);
}
/**
* Add campaign batch to queue for processing
*/
public static async queueCampaignBatch(data: CampaignBatchJobData): Promise<Job<CampaignBatchJobData>> {
return campaignQueue.add('process-batch', data, {
jobId: `campaign-${data.campaignId}-batch-${data.batchNumber}`,
});
}
/**
* Add workflow step to queue for execution
*/
public static async queueWorkflowStep(
executionId: string,
stepId: string,
delay?: number,
): Promise<Job<WorkflowStepJobData>> {
return workflowQueue.add(
'process-step',
{executionId, stepId, type: 'process-step'},
{
delay,
jobId: `workflow-${executionId}-${stepId}`,
},
);
}
/**
* Queue a timeout handler for WAIT_FOR_EVENT steps
*/
public static async queueWorkflowTimeout(
executionId: string,
stepId: string,
stepExecutionId: string,
timeoutMs: number,
): Promise<Job<WorkflowStepJobData>> {
return workflowQueue.add(
'timeout',
{executionId, stepId, stepExecutionId, type: 'timeout'},
{
delay: timeoutMs,
jobId: `workflow-timeout-${stepExecutionId}`,
},
);
}
/**
* Cancel a queued timeout job
*/
public static async cancelWorkflowTimeout(stepExecutionId: string): Promise<void> {
const jobId = `workflow-timeout-${stepExecutionId}`;
const job = await workflowQueue.getJob(jobId);
if (job) {
await job.remove();
signale.info(`[QUEUE] Cancelled timeout job ${jobId}`);
}
}
/**
* Schedule campaign for future sending
*/
public static async scheduleCampaign(campaignId: string, scheduledFor: Date): Promise<Job<ScheduledCampaignJobData>> {
const delay = scheduledFor.getTime() - Date.now();
return scheduledQueue.add(
'send-scheduled-campaign',
{campaignId},
{
delay: Math.max(0, delay),
jobId: `scheduled-campaign-${campaignId}`,
},
);
}
/**
* Cancel scheduled campaign
*/
public static async cancelScheduledCampaign(campaignId: string): Promise<void> {
const jobId = `scheduled-campaign-${campaignId}`;
const job = await scheduledQueue.getJob(jobId);
if (job) {
await job.remove();
}
}
/**
* Queue contact import job
*/
public static async queueImport(
projectId: string,
csvData: string,
filename: string,
): Promise<Job<ContactImportJobData>> {
return importQueue.add(
'import-contacts',
{projectId, csvData, filename},
{
jobId: `import-${projectId}-${Date.now()}`,
},
);
}
/**
* Get import job status and progress
* @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, 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;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue bulk contact action job
*/
public static async queueBulkContactAction(
projectId: string,
contactIds: string[],
operation: 'subscribe' | 'unsubscribe' | 'delete',
): Promise<Job<BulkContactActionJobData>> {
return bulkContactQueue.add(
'bulk-contact-action',
{projectId, contactIds, operation},
{
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
},
);
}
/**
* Get bulk action job status and progress
* @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, 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;
const failedReason = job.failedReason;
return {
id: job.id,
state,
progress,
result: returnValue,
data: job.data,
failedReason,
};
}
/**
* Queue segment count update job
*/
public static async queueSegmentCountUpdate(projectId?: string): Promise<Job<SegmentCountJobData>> {
return segmentCountQueue.add(
'update-segment-counts',
{projectId},
{
jobId: projectId ? `segment-count-${projectId}-${Date.now()}` : `segment-count-all-${Date.now()}`,
},
);
}
/**
* Get queue statistics
*/
public static async getStats() {
const [
emailCounts,
campaignCounts,
workflowCounts,
scheduledCounts,
importCounts,
segmentCountCounts,
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
workflowQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
scheduledQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
importQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
email: emailCounts,
campaign: campaignCounts,
workflow: workflowCounts,
scheduled: scheduledCounts,
import: importCounts,
segmentCount: segmentCountCounts,
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
};
}
/**
* Pause all queues (for maintenance)
*/
public static async pauseAll(): Promise<void> {
await Promise.all([
emailQueue.pause(),
campaignQueue.pause(),
workflowQueue.pause(),
scheduledQueue.pause(),
importQueue.pause(),
segmentCountQueue.pause(),
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
]);
}
/**
* Resume all queues
*/
public static async resumeAll(): Promise<void> {
await Promise.all([
emailQueue.resume(),
campaignQueue.resume(),
workflowQueue.resume(),
scheduledQueue.resume(),
importQueue.resume(),
segmentCountQueue.resume(),
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
]);
}
/**
* Clean old jobs (should be run periodically)
*/
public static async cleanOldJobs(): Promise<void> {
const gracePeriod = 24 * 60 * 60 * 1000; // 24 hours
await Promise.all([
emailQueue.clean(gracePeriod, 1000, 'completed'),
emailQueue.clean(gracePeriod * 7, 1000, 'failed'), // Keep failed jobs for 7 days
campaignQueue.clean(gracePeriod, 100, 'completed'),
campaignQueue.clean(gracePeriod * 7, 500, 'failed'),
workflowQueue.clean(gracePeriod, 1000, 'completed'),
workflowQueue.clean(gracePeriod * 7, 1000, 'failed'),
scheduledQueue.clean(gracePeriod, 100, 'completed'),
scheduledQueue.clean(gracePeriod * 7, 500, 'failed'),
importQueue.clean(gracePeriod, 50, 'completed'),
importQueue.clean(gracePeriod * 7, 100, 'failed'),
segmentCountQueue.clean(gracePeriod, 10, 'completed'),
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
]);
}
/**
* Cancel all pending jobs for a specific project
* This should be called when a project is disabled
*/
public static async cancelAllProjectJobs(projectId: string): Promise<void> {
signale.info(`[QUEUE] Cancelling all pending jobs for project ${projectId}`);
// Cancel all scheduled campaigns for this project
const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']);
for (const job of scheduledCampaigns) {
// We need to check if the campaign belongs to this project
// by looking up the campaign in the database
const campaign = await prisma.campaign.findUnique({
where: {id: job.data.campaignId},
select: {projectId: true},
});
if (campaign?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed scheduled campaign job ${job.id}`);
}
}
// Cancel all pending emails for this project
const pendingEmails = await emailQueue.getJobs(['waiting', 'delayed']);
for (const job of pendingEmails) {
const email = await prisma.email.findUnique({
where: {id: job.data.emailId},
select: {projectId: true},
});
if (email?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed email job ${job.id}`);
}
}
// Cancel all pending campaign batches for this project
const campaignBatches = await campaignQueue.getJobs(['waiting', 'delayed']);
for (const job of campaignBatches) {
const campaign = await prisma.campaign.findUnique({
where: {id: job.data.campaignId},
select: {projectId: true},
});
if (campaign?.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed campaign batch job ${job.id}`);
}
}
// Cancel all pending workflow steps for this project
const workflowSteps = await workflowQueue.getJobs(['waiting', 'delayed']);
for (const job of workflowSteps) {
const execution = await prisma.workflowExecution.findUnique({
where: {id: job.data.executionId},
select: {workflow: {select: {projectId: true}}},
});
if (execution?.workflow.projectId === projectId) {
await job.remove();
signale.info(`[QUEUE] Removed workflow step job ${job.id}`);
}
}
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
}
/**
* Close all queue connections
*/
public static async closeAll(): Promise<void> {
await Promise.all([
emailQueue.close(),
campaignQueue.close(),
workflowQueue.close(),
scheduledQueue.close(),
importQueue.close(),
segmentCountQueue.close(),
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
]);
}
}
+164
View File
@@ -0,0 +1,164 @@
import {
CreateBucketCommand,
HeadBucketCommand,
PutBucketPolicyCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import crypto from 'crypto';
import signale from 'signale';
import {
S3_ACCESS_KEY_ID,
S3_ACCESS_KEY_SECRET,
S3_BUCKET,
S3_ENABLED,
S3_ENDPOINT,
S3_FORCE_PATH_STYLE,
S3_PUBLIC_URL,
} from '../app/constants.js';
/**
* S3-compatible storage client for Minio
*/
let s3Client: S3Client | null = null;
if (S3_ENABLED) {
s3Client = new S3Client({
region: 'us-east-1', // Minio doesn't use regions, but AWS SDK requires this parameter
endpoint: S3_ENDPOINT,
credentials: {
accessKeyId: S3_ACCESS_KEY_ID,
secretAccessKey: S3_ACCESS_KEY_SECRET,
},
forcePathStyle: S3_FORCE_PATH_STYLE, // Required for Minio (uses path-style URLs)
});
}
/**
* Initialize the S3 bucket if it doesn't exist
*/
export async function initializeBucket(): Promise<void> {
if (!s3Client) {
throw new Error('S3 is not enabled');
}
let bucketExists = true;
try {
await s3Client.send(
new HeadBucketCommand({
Bucket: S3_BUCKET,
}),
);
} catch (error: unknown) {
const isNotFoundError =
error &&
typeof error === 'object' &&
(('name' in error && error.name === 'NotFound') ||
('$metadata' in error &&
error.$metadata &&
typeof error.$metadata === 'object' &&
'httpStatusCode' in error.$metadata &&
error.$metadata.httpStatusCode === 404));
if (isNotFoundError) {
bucketExists = false;
// Bucket doesn't exist, create it
try {
await s3Client.send(
new CreateBucketCommand({
Bucket: S3_BUCKET,
}),
);
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
} catch (createError) {
signale.error('[S3] Failed to create bucket:', createError);
throw createError;
}
} else {
signale.error('[S3] Failed to check bucket:', error);
throw error;
}
}
// Set public read policy for the bucket (both for new and existing buckets)
try {
const bucketPolicy = {
Version: '2012-10-17',
Statement: [
{
Sid: 'PublicReadGetObject',
Effect: 'Allow',
Principal: '*',
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${S3_BUCKET}/*`],
},
],
};
await s3Client.send(
new PutBucketPolicyCommand({
Bucket: S3_BUCKET,
Policy: JSON.stringify(bucketPolicy),
}),
);
if (!bucketExists) {
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
}
} catch (policyError) {
signale.error('[S3] Failed to set bucket policy:', policyError);
// Don't throw - bucket was created but policy failed
}
}
interface UploadFileParams {
file: Buffer;
filename: string;
contentType: string;
projectId: string;
}
interface UploadFileResult {
url: string;
key: string;
}
/**
* Upload a file to S3/Minio
*/
export async function uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
if (!s3Client) {
throw new Error('S3 is not enabled');
}
const {file, filename, contentType, projectId} = params;
// Generate a unique key for the file
const timestamp = Date.now();
const randomString = crypto.randomBytes(8).toString('hex');
const extension = filename.split('.').pop();
const key = `${projectId}/${timestamp}-${randomString}.${extension}`;
// Upload to S3/Minio
await s3Client.send(
new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
Body: file,
ContentType: contentType,
}),
);
// Construct public URL
const url = `${S3_PUBLIC_URL}/${key}`;
return {url, key};
}
/**
* Check if S3 is enabled and configured
*/
export function isS3Enabled(): boolean {
return S3_ENABLED;
}
+332
View File
@@ -0,0 +1,332 @@
import {SES} from '@aws-sdk/client-ses';
import signale from 'signale';
import {
AWS_SES_ACCESS_KEY_ID,
AWS_SES_REGION,
AWS_SES_SECRET_ACCESS_KEY,
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED,
} from '../app/constants.js';
/**
* AWS SES Client
*/
export const ses = new SES({
apiVersion: '2010-12-01',
region: AWS_SES_REGION,
credentials: {
accessKeyId: AWS_SES_ACCESS_KEY_ID,
secretAccessKey: AWS_SES_SECRET_ACCESS_KEY,
},
});
interface SendRawEmailParams {
from: {
name: string;
email: string;
};
to: string[] | {name?: string; email: string}[];
content: {
subject: string;
html: string;
};
reply?: string;
headers?: Record<string, string> | null;
attachments?:
| {
filename: string;
content: string; // Base64 encoded
contentType: string;
contentId?: string;
disposition?: 'attachment' | 'inline';
}[]
| null;
tracking?: boolean;
}
/**
* Break long lines to comply with email RFC standards
*/
function breakLongLines(input: string, maxLineLength: number, isBase64 = false): string {
if (isBase64) {
// For base64 content, break at exact intervals without looking for spaces
const result = [];
for (let i = 0; i < input.length; i += maxLineLength) {
result.push(input.substring(i, i + maxLineLength));
}
return result.join('\n');
} else {
// For text content, break at spaces when possible
const lines = input.split('\n');
const result = [];
for (let line of lines) {
while (line.length > maxLineLength) {
let pos = maxLineLength;
while (pos > 0 && line[pos] !== ' ') {
pos--;
}
if (pos === 0) {
pos = maxLineLength;
}
result.push(line.substring(0, pos));
line = line.substring(pos).trim();
}
result.push(line);
}
return result.join('\n');
}
}
/**
* Send a raw email via AWS SES with full MIME formatting
*/
export async function sendRawEmail({
from,
to,
content,
reply,
headers,
attachments,
tracking = true,
}: SendRawEmailParams): Promise<{messageId: string}> {
// Check if the body contains an unsubscribe link
const regex = /unsubscribe\/([a-f\d-]+)"/;
const containsUnsubscribeLink = regex.exec(content.html);
let unsubscribeHeader = '';
if (containsUnsubscribeLink?.[1]) {
const unsubscribeId = containsUnsubscribeLink[1];
unsubscribeHeader = `List-Unsubscribe: <${DASHBOARD_URI}/unsubscribe/${unsubscribeId}>`;
}
// Generate unique boundaries for multipart messages
const 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
.map(recipient => {
if (typeof recipient === 'string') {
return recipient;
} else {
return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
}
})
.join(', ');
// Extract just email addresses for Destinations (SES requirement)
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
// 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
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
`;
// 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)}
--${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)}`;
}
rawMessage += `\n--${mixedBoundary}--`;
}
// Determine which configuration set to use
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
const configurationSetName =
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
// Send via SES
const response = await ses.sendRawEmail({
Destinations: destinations,
ConfigurationSetName: configurationSetName,
RawMessage: {
Data: new TextEncoder().encode(rawMessage),
},
Source: `${from.name} <${from.email}>`,
});
if (!response.MessageId) {
throw new Error('Could not send email');
}
return {messageId: response.MessageId};
}
/**
* Get verification attributes for multiple domain identities
*/
export const getIdentities = async (domains: string[]): Promise<{domain: string; status: string}[]> => {
const res = await ses.getIdentityVerificationAttributes({
Identities: domains,
});
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
return parsedResult.map(obj => {
return {domain: obj[0], status: obj[1].VerificationStatus ?? 'NotStarted'};
});
};
/**
* Verify a domain and get DKIM tokens for DNS configuration
*/
export const verifyDomain = async (domain: string): Promise<string[]> => {
// Verify DKIM for the domain
const DKIM = await ses.verifyDomainDkim({Domain: domain});
// Set custom MAIL FROM domain (plunk.yourdomain.com)
await ses.setIdentityMailFromDomain({
Identity: domain,
MailFromDomain: `plunk.${domain}`,
});
return DKIM.DkimTokens ?? [];
};
/**
* Get DKIM verification attributes for a domain
*/
export const getDomainVerificationAttributes = async (domain: string) => {
const attributes = await ses.getIdentityDkimAttributes({
Identities: [domain],
});
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
if (parsedAttributes.length === 0) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
const firstAttribute = parsedAttributes[0];
if (!firstAttribute) {
return {
domain,
tokens: [],
status: 'NotStarted',
};
}
return {
domain: firstAttribute[0],
tokens: firstAttribute[1].DkimTokens ?? [],
status: firstAttribute[1].DkimVerificationStatus ?? 'NotStarted',
};
};
/**
* Disable bounce/complaint forwarding for a verified domain
*/
export const disableFeedbackForwarding = async (domain: string): Promise<void> => {
await ses.setIdentityFeedbackForwardingEnabled({
Identity: domain,
ForwardingEnabled: false,
});
};
/**
* 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
*/
export const getSendingQuota = async (): Promise<{
maxSendRate: number;
max24HourSend: number;
sentLast24Hours: number;
} | null> => {
try {
const quota = await ses.getSendQuota({});
return {
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
sentLast24Hours: quota.SentLast24Hours ?? 0,
};
} catch (error) {
signale.error('[SES] Failed to fetch sending quota:', error);
return null;
}
};
+453
View File
@@ -0,0 +1,453 @@
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
* These limits protect AWS SES reputation and prevent account suspension
*/
const SECURITY_THRESHOLDS = {
// Minimum emails required before enforcing limits (prevents false positives)
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
BOUNCE_7DAY_WARNING: 5,
BOUNCE_7DAY_CRITICAL: 10,
BOUNCE_ALLTIME_WARNING: 4,
BOUNCE_ALLTIME_CRITICAL: 8,
// Complaint rate thresholds (spam reports)
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 {
total: number;
bounces: number;
complaints: number;
bounceRate: number;
complaintRate: number;
}
interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
sevenDay: RateData;
allTime: RateData;
violations: string[];
warnings: string[];
}
export class SecurityService {
private static readonly CACHE_TTL = 300; // 5 minutes
/**
* Get security status for a project (with caching)
*/
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
try {
// Try to get from cache first
const cacheKey = Keys.Security.rates(projectId);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Calculate fresh data
const status = await this.calculateSecurityStatus(projectId);
// Cache the result
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(status));
return status;
} catch (error) {
signale.error('[SECURITY] Failed to get security status:', error);
// Return safe defaults on error
return {
projectId,
isHealthy: true,
shouldDisable: false,
sevenDay: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
allTime: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
violations: [],
warnings: [],
};
}
}
/**
* Check security status and auto-disable project if thresholds are exceeded
* This should be called after bounce/complaint events are processed
*/
public static async checkAndEnforceSecurityLimits(projectId: string): Promise<void> {
try {
// Invalidate cache to get fresh data
await this.invalidateCache(projectId);
// Get current security status
const status = await this.getSecurityStatus(projectId);
// If project should be disabled, disable it (only if auto-disable is enabled)
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
await this.disableProject(projectId, status);
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
// Log critical violations but don't auto-disable (self-hosted mode)
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
}
} else if (status.warnings.length > 0) {
// Log warnings for monitoring
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
// Get project name for notification
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {name: true},
});
if (project) {
// Send notification about security warning
await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings);
}
}
} catch (error) {
// Log error but don't throw - we don't want security checks to break the webhook
signale.error(`[SECURITY] Failed to check security limits for project ${projectId}:`, error);
}
}
/**
* Invalidate cached security data for a project
* Should be called after bounce/complaint events
*/
public static async invalidateCache(projectId: string): Promise<void> {
try {
const cacheKey = Keys.Security.rates(projectId);
await redis.del(cacheKey);
} catch (error) {
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
}
}
/**
* Check if a user is a member of any disabled project
* Users with disabled projects cannot create new projects
*/
public static async userHasDisabledProject(userId: string): Promise<{
hasDisabledProject: boolean;
disabledProjectNames: string[];
}> {
return MembershipService.userHasDisabledProject(userId);
}
/**
* Check if a specific project is disabled
*/
public static async isProjectDisabled(projectId: string): Promise<boolean> {
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
return project?.disabled ?? false;
}
/**
* Get a project's security metrics (for admin/dashboard display)
*/
public static async getProjectSecurityMetrics(projectId: string): Promise<{
status: SecurityStatus;
thresholds: typeof SECURITY_THRESHOLDS;
isDisabled: boolean;
}> {
const [status, project] = await Promise.all([
this.getSecurityStatus(projectId),
prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
}),
]);
return {
status,
thresholds: SECURITY_THRESHOLDS,
isDisabled: project?.disabled ?? false,
};
}
/**
* Calculate bounce and complaint rates for a project
*/
private static async calculateRates(projectId: string, startDate?: Date): Promise<RateData> {
const where = {
projectId,
...(startDate && {
createdAt: {
gte: startDate,
},
}),
};
// Get counts in parallel for performance
const [total, bounces, complaints] = await Promise.all([
prisma.email.count({where}),
prisma.email.count({
where: {
...where,
bouncedAt: {not: null},
},
}),
prisma.email.count({
where: {
...where,
complainedAt: {not: null},
},
}),
]);
const bounceRate = total > 0 ? (bounces / total) * 100 : 0;
const complaintRate = total > 0 ? (complaints / total) * 100 : 0;
return {
total,
bounces,
complaints,
bounceRate,
complaintRate,
};
}
/**
* Calculate security status without caching
*/
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get 7-day and all-time rates in parallel
const [sevenDay, allTime] = await Promise.all([
this.calculateRates(projectId, sevenDaysAgo),
this.calculateRates(projectId),
]);
const violations: string[] = [];
const warnings: string[] = [];
// Only enforce if minimum emails threshold is met
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
// 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)}%, ${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 &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
) {
warnings.push(
`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 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 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.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
) {
warnings.push(
`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)`,
);
}
}
return {
projectId,
isHealthy: violations.length === 0,
shouldDisable: violations.length > 0,
sevenDay,
allTime,
violations,
warnings,
};
}
/**
* Disable a project due to security violations
*/
private static async disableProject(projectId: string, status: SecurityStatus): Promise<void> {
try {
// Check if already disabled to avoid duplicate logs
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {id: true, disabled: true, name: true},
});
if (!project) {
signale.error(`[SECURITY] Project ${projectId} not found`);
return;
}
if (project.disabled) {
// Already disabled, just log the current violations
signale.warn(
`[SECURITY] Project ${projectId} (${project.name}) already disabled. Current violations:`,
status.violations,
);
return;
}
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true},
});
// Log critical security event
signale.error(
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
status.violations,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
// Cancel all pending jobs for this project
try {
await QueueService.cancelAllProjectJobs(projectId);
signale.info(`[SECURITY] Cancelled all pending jobs for project ${projectId}`);
} catch (error) {
signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error);
}
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: status.violations,
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
);
}
} catch (emailError) {
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
}
}
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
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 class TemplateService {
/**
* Get all templates for a project with pagination
*/
public static async list(
projectId: string,
page = 1,
pageSize = 20,
search?: string,
type?: Template['type'],
): Promise<PaginatedResponse<Template>> {
const skip = (page - 1) * pageSize;
const where: Prisma.TemplateWhereInput = {
projectId,
...(type ? {type} : {}),
...(search
? {
OR: [
{name: {contains: search, mode: 'insensitive' as const}},
{description: {contains: search, mode: 'insensitive' as const}},
{subject: {contains: search, mode: 'insensitive' as const}},
],
}
: {}),
};
const [templates, total] = await Promise.all([
prisma.template.findMany({
where,
skip,
take: pageSize,
orderBy: {createdAt: 'desc'},
}),
prisma.template.count({where}),
]);
return {
data: templates,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
/**
* Get a single template by ID
*/
public static async get(projectId: string, templateId: string): Promise<Template> {
const template = await prisma.template.findFirst({
where: {
id: templateId,
projectId,
},
});
if (!template) {
throw new HttpException(404, 'Template not found');
}
return template;
}
/**
* Create a new template
*/
public static async create(
projectId: string,
data: {
name: string;
description?: string;
subject: string;
body: string;
from: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
return prisma.template.create({
data: {
projectId,
name: data.name,
description: data.description,
subject: data.subject,
body: data.body,
from: data.from,
fromName: data.fromName,
replyTo: data.replyTo,
type: data.type ?? 'MARKETING',
},
});
}
/**
* Update a template
*/
public static async update(
projectId: string,
templateId: string,
data: {
name?: string;
description?: string;
subject?: string;
body?: string;
from?: string;
fromName?: string | null;
replyTo?: string | null;
type?: Template['type'];
},
): Promise<Template> {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
const updateData = {
...buildEmailFieldsUpdate(data),
...(data.type !== undefined ? {type: data.type} : {}),
} as Prisma.TemplateUpdateInput;
return prisma.template.update({
where: {id: templateId},
data: updateData,
});
}
/**
* Delete a template
*/
public static async delete(projectId: string, templateId: string): Promise<void> {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
// Check if template is used in any workflows
const workflowSteps = await prisma.workflowStep.count({
where: {
templateId,
workflow: {projectId},
},
});
if (workflowSteps > 0) {
throw new HttpException(
409,
'Cannot delete template: it is currently used in workflow steps. Remove it from workflows first.',
);
}
await prisma.template.delete({
where: {id: templateId},
});
}
/**
* Duplicate a template
*/
public static async duplicate(projectId: string, templateId: string): Promise<Template> {
const template = await this.get(projectId, templateId);
// Create a new template with the same data
return prisma.template.create({
data: {
projectId,
name: `${template.name} (Copy)`,
description: template.description,
subject: template.subject,
body: template.body,
from: template.from,
fromName: template.fromName,
replyTo: template.replyTo,
type: template.type,
},
});
}
/**
* Get template usage statistics
*/
public static async getUsage(projectId: string, templateId: string) {
// Verify template exists and belongs to project
await this.get(projectId, templateId);
const [workflowStepsCount, emailsCount] = await Promise.all([
prisma.workflowStep.count({
where: {
templateId,
workflow: {projectId},
},
}),
prisma.email.count({
where: {
templateId,
projectId,
},
}),
]);
return {
workflowSteps: workflowStepsCount,
emailsSent: emailsCount,
};
}
}
+103
View File
@@ -0,0 +1,103 @@
import dayjs from 'dayjs';
import {API_URI, NODE_ENV} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {wrapRedis} from '../database/redis.js';
import {Keys} from './keys.js';
/**
* Extract base domain from URL for cookie sharing across subdomains
* e.g., "http://api.example.com" -> ".example.com"
* e.g., "http://api.localhost" -> ".localhost"
* e.g., "http://app.plunk.local" -> ".plunk.local"
*/
function getCookieDomain(): string | undefined {
if (NODE_ENV === 'development') {
return undefined;
}
try {
const url = new URL(API_URI);
const hostname = url.hostname;
// For localhost or IP addresses, don't set a domain
if (hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
return undefined;
}
// Extract base domain (last two parts for most domains, or .localhost)
const parts = hostname.split('.');
if (parts.length >= 2) {
// For *.localhost, 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('.')}`;
}
return undefined;
} catch {
return undefined;
}
}
export class UserService {
public static readonly COOKIE_NAME = 'next_token';
public static async id(id: string) {
return wrapRedis(Keys.User.id(id), async () => {
return prisma.user.findUnique({where: {id}});
});
}
public static async email(email: string) {
if (!email) {
return null;
}
return wrapRedis(Keys.User.email(email), async () => {
return prisma.user.findFirst({
where: {
email: {
equals: email,
mode: 'insensitive',
},
},
});
});
}
public static async projects(userId: string) {
const memberships = await prisma.user.findUnique({where: {id: userId}}).memberships({
include: {
project: true,
},
});
return memberships ? memberships.map(({project}) => project) : [];
}
/**
* Generates cookie options
* @param expires An optional expiry for this cookie (useful for a logout)
*/
public static cookieOptions(expires?: Date) {
// Check if using HTTPS from API_URI
const isHttps = NODE_ENV === 'development' ? false : API_URI.startsWith('https://');
return {
httpOnly: true,
expires: expires ?? dayjs().add(7, 'days').toDate(),
secure: isHttps,
sameSite: isHttps ? 'none' : 'lax',
path: '/',
domain: getCookieDomain(),
} as const;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,784 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
// Mock the stripe client to avoid actual Stripe API calls
vi.mock('../../app/stripe.js', () => ({
stripe: {
customers: {
retrieve: vi.fn().mockResolvedValue({
deleted: false,
currency: 'usd',
}),
},
},
}));
describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
});
describe('Revenue Protection - Limit Enforcement', () => {
it('should BLOCK transactional emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitTransactional: 5},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Attempt to send 6th email should fail
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK campaign emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 3},
});
// Create 3 campaign emails
for (let i = 0; i < 3; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// 4th should fail
const campaign = await factories.createCampaign({projectId});
await expect(
EmailService.sendCampaignEmail({
projectId,
contactId,
campaignId: campaign.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK workflow emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitWorkflows: 2},
});
// Create 2 workflow emails
for (let i = 0; i < 2; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// 3rd should fail
await expect(
EmailService.sendWorkflowEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should ALLOW emails when limit is null (unlimited)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitTransactional: null,
// Set one other limit to trigger per-category mode (not free tier)
billingLimitCampaigns: 1000,
},
});
// Create 100 emails
for (let i = 0; i < 100; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Should still allow more
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(result.allowed).toBe(true);
expect(result.limit).toBeNull();
});
it('should enforce limits independently per source type', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitTransactional: 5,
billingLimitCampaigns: 5,
billingLimitWorkflows: 5,
},
});
// Max out transactional
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Transactional should be blocked
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(transactionalCheck.allowed).toBe(false);
// Campaign should still be allowed
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(true);
// Workflow should still be allowed
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
});
});
describe('Warning Threshold (80%)', () => {
it('should warn when usage reaches 80% of limit', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(true);
expect(result.percentage).toBeGreaterThanOrEqual(80);
expect(result.message).toMatch(/warning/i);
});
it('should not warn when usage is below 80%', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(false);
expect(result.message).toBeUndefined();
});
});
describe('Cache Performance & Fallback', () => {
it('should use cached usage counts to avoid DB queries', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// First call - should query DB and cache
const usage1 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Add email directly to DB (bypassing cache increment)
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
// Second call within cache TTL - should return cached value (not reflect new email)
const usage2 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(usage2).toBe(usage1); // Same as cached value
});
it('should increment cache after successful email send', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
await BillingLimitService.invalidateCache(projectId);
const initialUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Send email (should increment cache)
await EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
const finalUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(finalUsage).toBe(initialUsage + 1);
});
it('should handle Redis failure gracefully without blocking emails', async () => {
// Mock Redis failure
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('Redis connection failed'));
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Should fall back to DB and still work
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
});
it('should handle invalid project ID gracefully', async () => {
// Non-existent project should not crash, should allow email (fail-open)
const result = await BillingLimitService.checkLimit('non-existent-project', EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.usage).toBe(0);
expect(result.limit).toBeNull();
});
});
describe('Monthly Reset Behavior', () => {
it('should only count emails from current calendar month', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// 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({
data: {
projectId,
contactId,
subject: 'Old',
body: 'Old',
from: 'test@example.com',
sourceType: EmailSourceType.CAMPAIGN,
status: 'SENT',
createdAt: lastMonth,
},
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const usage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Should only count this month's email
expect(usage).toBe(1);
});
});
describe('Complete Limits Overview', () => {
it('should return all category limits and usage', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: 100,
billingLimitCampaigns: 200,
billingLimitTransactional: null, // Unlimited
},
});
// Create various emails
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
expect(limits.workflows.limit).toBe(100);
expect(limits.workflows.usage).toBe(1);
expect(limits.workflows.percentage).toBe(1);
expect(limits.workflows.isWarning).toBe(false);
expect(limits.workflows.isBlocked).toBe(false);
expect(limits.campaigns.limit).toBe(200);
expect(limits.campaigns.usage).toBe(2);
expect(limits.campaigns.percentage).toBe(1);
expect(limits.transactional.limit).toBeNull();
expect(limits.transactional.usage).toBe(0);
expect(limits.transactional.percentage).toBe(0);
});
});
describe('Race Condition Behavior', () => {
it('should check limits before each email send (may allow some concurrent sends)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 8 existing emails (80% of limit)
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// Try to send 5 emails simultaneously
const promises = Array.from({length: 5}, () =>
EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
);
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
// Note: Due to race conditions, multiple may succeed before limit is enforced
// The limit check happens at send time, not atomically
// At least some should succeed (we're under limit when we start)
expect(successful).toBeGreaterThan(0);
// Eventually, some should fail once limit is hit
// Total emails created should not greatly exceed limit
const totalEmails = await prisma.email.count({
where: {projectId, sourceType: EmailSourceType.CAMPAIGN},
});
// Should be close to limit (8 existing + some new <= ~13 due to races)
expect(totalEmails).toBeLessThanOrEqual(15);
});
});
describe('Free Tier Total Limit (1000 emails/month across all types)', () => {
it('should enforce 1000 email total limit for free tier projects', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project has no subscription and no custom limits set
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 1000 emails total across different types
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 400; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// All three types should now be blocked since total is 1000
await expect(
EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
await expect(
EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/free tier limit/i);
});
it('should count all email types towards free tier total limit', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 200 of each type (600 total)
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Check limits for each type - all should report 600 total usage
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
// All types should see the same total usage (600) and same limit (1000)
expect(workflowCheck.usage).toBe(600);
expect(workflowCheck.limit).toBe(1000);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.percentage).toBe(60);
expect(campaignCheck.usage).toBe(600);
expect(campaignCheck.limit).toBe(1000);
expect(campaignCheck.allowed).toBe(true);
expect(transactionalCheck.usage).toBe(600);
expect(transactionalCheck.limit).toBe(1000);
expect(transactionalCheck.allowed).toBe(true);
});
it('should show warning at 80% of free tier total limit (800 emails)', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 800 emails spread across types
for (let i = 0; i < 300; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 250; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 250; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// All types should show warning
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.warning).toBe(true);
expect(workflowCheck.usage).toBe(800);
expect(workflowCheck.percentage).toBeGreaterThanOrEqual(80);
expect(workflowCheck.message).toMatch(/warning/i);
});
it('should allow free tier to send different distribution of email types', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create 900 transactional, 50 campaigns, 49 workflows (999 total - under limit)
for (let i = 0; i < 900; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
for (let i = 0; i < 50; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 49; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// Should still allow one more email of any type
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
expect(workflowCheck.usage).toBe(999);
// Send the 1000th email (should succeed)
await EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
await BillingLimitService.invalidateCache(projectId);
// Now all types should be blocked
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(false);
expect(campaignCheck.usage).toBe(1000);
});
it('should distinguish free tier from paid tier with per-category limits', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Set custom per-category limits (paid tier behavior)
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: 100,
billingLimitCampaigns: 200,
billingLimitTransactional: 300,
},
});
// Create 150 workflow emails (exceeds workflow limit but under total)
for (let i = 0; i < 150; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// Workflow should be blocked (150 > 100)
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(false);
expect(workflowCheck.usage).toBe(150);
expect(workflowCheck.limit).toBe(100);
// But campaigns should still be allowed (independent limit)
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(true);
expect(campaignCheck.usage).toBe(0);
expect(campaignCheck.limit).toBe(200);
});
it('should show shared limit for all categories in getLimitsAndUsage for free tier', async () => {
// Create a contact for this test
const contact = await factories.createContact({projectId});
// Free tier project
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: null,
billingLimitCampaigns: null,
billingLimitTransactional: null,
},
});
// Create various emails
for (let i = 0; i < 100; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.WORKFLOW,
});
}
for (let i = 0; i < 200; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.CAMPAIGN,
});
}
for (let i = 0; i < 150; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
// All categories should show the same total usage (450) and limit (1000)
expect(limits.workflows.limit).toBe(1000);
expect(limits.workflows.usage).toBe(450);
expect(limits.workflows.percentage).toBe(45);
expect(limits.campaigns.limit).toBe(1000);
expect(limits.campaigns.usage).toBe(450);
expect(limits.transactional.limit).toBe(1000);
expect(limits.transactional.usage).toBe(450);
});
});
});
@@ -0,0 +1,592 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock STRIPE_ENABLED for billing limit tests
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
STRIPE_ENABLED: true,
STRIPE_SK: 'sk_test_mock_key_for_testing',
};
});
describe('CampaignService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('create', () => {
it('should create a campaign with ALL audience type', async () => {
const campaign = await CampaignService.create(projectId, {
name: 'Test Campaign',
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.ALL,
});
expect(campaign).toBeDefined();
expect(campaign.name).toBe('Test Campaign');
expect(campaign.status).toBe(CampaignStatus.DRAFT);
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
});
it('should create a campaign with SEGMENT audience type', async () => {
const segment = await factories.createSegment(projectId, {name: 'VIP Users'});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.segmentId).toBe(segment.id);
});
it('should throw error when creating SEGMENT campaign without segmentId', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
}),
).rejects.toThrow('Segment ID is required');
});
it('should throw error when segment does not exist', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: 'non-existent-segment',
}),
).rejects.toThrow('Segment not found');
});
});
describe('update', () => {
it('should update a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
name: 'Original Name',
status: CampaignStatus.DRAFT,
});
const updated = await CampaignService.update(projectId, campaign.id, {
name: 'Updated Name',
subject: 'Updated Subject',
});
expect(updated.name).toBe('Updated Name');
expect(updated.subject).toBe('Updated Subject');
});
it('should throw error when updating non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.update(projectId, campaign.id, {name: 'New Name'})).rejects.toThrow(
'Cannot update campaign that is sending or has been sent',
);
});
});
describe('delete', () => {
it('should delete a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await CampaignService.delete(projectId, campaign.id);
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
expect(deleted).toBeNull();
});
it('should not delete a non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.delete(projectId, campaign.id)).rejects.toThrow('Can only delete draft campaigns');
});
});
describe('duplicate', () => {
it('should duplicate a campaign with (Copy) suffix', async () => {
const original = await factories.createCampaign({
projectId,
name: 'Original Campaign',
});
const duplicate = await CampaignService.duplicate(projectId, original.id);
expect(duplicate.name).toBe('Original Campaign (Copy)');
expect(duplicate.subject).toBe(original.subject);
expect(duplicate.body).toBe(original.body);
expect(duplicate.status).toBe(CampaignStatus.DRAFT);
expect(duplicate.id).not.toBe(original.id);
});
});
describe('list', () => {
it('should list campaigns with pagination', async () => {
// Create 25 campaigns using bulk insert to avoid memory issues
const campaignData = Array.from({length: 25}, (_, i) => ({
projectId,
name: `Campaign ${i}`,
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
status: 'DRAFT' as const,
}));
await prisma.campaign.createMany({data: campaignData});
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
expect(result.data).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
expect(result.page).toBe(1);
});
it('should filter campaigns by status', async () => {
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.data).toHaveLength(2);
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
describe('get', () => {
it('should get a campaign by id', async () => {
const campaign = await factories.createCampaign({projectId, name: 'Test Campaign'});
const retrieved = await CampaignService.get(projectId, campaign.id);
expect(retrieved.id).toBe(campaign.id);
expect(retrieved.name).toBe('Test Campaign');
});
it('should throw error when campaign does not exist', async () => {
await expect(CampaignService.get(projectId, 'non-existent-id')).rejects.toThrow('Campaign not found');
});
});
describe('Campaign + Segment Integration', () => {
it('should create campaign targeting a segment', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.SEGMENT);
expect(campaign.segmentId).toBe(segment.id);
});
it('should only send to contacts matching segment criteria', async () => {
// Create contacts - some match segment, some don't
const vipContact1 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const vipContact2 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const regularContact = await factories.createContact({
projectId,
subscribed: true,
data: {vip: false},
});
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// In a real scenario, the campaign processor would create emails
// For this test, we manually check which contacts match the segment filters
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const contacts = allContacts.filter(c => (c.data as Record<string, unknown>)?.vip === true);
// Should only include VIP contacts
expect(contacts).toHaveLength(2);
const contactIds = contacts.map(c => c.id);
expect(contactIds).toContain(vipContact1.id);
expect(contactIds).toContain(vipContact2.id);
expect(contactIds).not.toContain(regularContact.id);
});
it('should exclude unsubscribed contacts from segment campaigns', async () => {
const subscribedVip = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const _unsubscribedVip = await factories.createContact({
projectId,
subscribed: false,
data: {vip: true},
});
// Segment that requires BOTH vip AND subscribed
const segment = await factories.createSegment(projectId, {
name: 'Subscribed VIP Users',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// Verify only subscribed VIP is targeted
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const matching = allContacts.filter(
c => (c.data as Record<string, unknown>)?.vip === true && c.subscribed === true,
);
expect(matching).toHaveLength(1);
expect(matching[0].id).toBe(subscribedVip.id);
});
it('should handle campaigns for ALL audience type', async () => {
// Create mix of contacts
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: false}); // Should be excluded
const campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.ALL,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
expect(campaign.segmentId).toBeNull();
// Verify ALL campaigns should target subscribed contacts only
const subscribedContacts = await prisma.contact.findMany({
where: {projectId, subscribed: true},
});
expect(subscribedContacts).toHaveLength(2);
});
});
describe('Campaign Audience Validation', () => {
it('should calculate correct recipient count for segment campaigns', async () => {
// Create 5 contacts matching segment
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro'},
});
}
// Create 3 contacts not matching
for (let i = 0; i < 3; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free'},
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
const matching = await prisma.contact.count({
where: {
projectId,
data: {
path: ['plan'],
equals: 'pro',
},
},
});
expect(matching).toBe(5);
});
});
describe('send', () => {
it('should throw error when campaign has no recipients', async () => {
// Create campaign with no contacts in project
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
});
it('should send campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Create 5 subscribed contacts
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
// Create campaign targeting all contacts
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (5 recipients < 100 limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw 403 error when campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails (usage = 5)
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 15 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
});
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
// Set billing limit for campaigns to 20
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 20},
});
// Create 15 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 15; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create campaign with 10 subscribed contacts (would result in 25 total)
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
/Cannot schedule campaign.*exceed billing limit/i,
);
});
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
// Set billing limit to null (unlimited)
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: null},
});
// Create 1000 subscribed contacts
for (let i = 0; i < 1000; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully (no limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(1000);
});
it('should allow campaign that exactly reaches billing limit', async () => {
// Set billing limit for campaigns to 10
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 5 existing campaign emails
const contact = await factories.createContact({projectId, subscribed: true});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId: contact.id,
sourceType: 'CAMPAIGN',
});
}
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
for (let i = 0; i < 4; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
// Should send successfully because 5 + 5 = 10 (exactly at limit)
const sentCampaign = await CampaignService.send(projectId, campaign.id);
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
expect(sentCampaign.totalRecipients).toBe(5);
});
it('should throw error when campaign has already been sent', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
'Campaign has already been sent or is currently sending',
);
});
it('should schedule campaign successfully when recipients are under billing limit', async () => {
// Set billing limit for campaigns
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 50},
});
// Create 10 subscribed contacts
for (let i = 0; i < 10; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
audienceType: CampaignAudienceType.ALL,
});
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Should schedule successfully (10 recipients < 50 limit)
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
expect(scheduledCampaign.totalRecipients).toBe(10);
});
});
});
@@ -0,0 +1,704 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('ContactService - Duplicate Prevention & Data Merging', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Duplicate Email Prevention', () => {
it('should REJECT creating duplicate email in same project', async () => {
const email = 'test@example.com';
await ContactService.create(projectId, {email});
await expect(ContactService.create(projectId, {email})).rejects.toThrow(/already exists/i);
});
it('should ALLOW same email in different projects (multi-tenancy)', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const email = 'test@example.com';
const contact1 = await ContactService.create(project1.id, {email});
const contact2 = await ContactService.create(project2.id, {email});
expect(contact1.id).not.toBe(contact2.id);
expect(contact1.email).toBe(email);
expect(contact2.email).toBe(email);
});
it('should REJECT updating contact to duplicate email in same project', async () => {
const email1 = 'user1@example.com';
const email2 = 'user2@example.com';
await ContactService.create(projectId, {email: email1});
const contact2 = await ContactService.create(projectId, {email: email2});
await expect(ContactService.update(projectId, contact2.id, {email: email1})).rejects.toThrow(/already exists/i);
});
it('should ALLOW updating contact to same email (no-op)', async () => {
const email = 'test@example.com';
const contact = await ContactService.create(projectId, {email});
const updated = await ContactService.update(projectId, contact.id, {
email,
data: {firstName: 'John'},
});
expect(updated.email).toBe(email);
});
it('should handle race condition when creating same email simultaneously', async () => {
const email = 'race@example.com';
const promises = Array.from({length: 10}, () => ContactService.create(projectId, {email}));
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
expect(successful).toBe(1);
expect(failed).toBe(9);
const contacts = await prisma.contact.findMany({
where: {projectId, email},
});
expect(contacts).toHaveLength(1);
});
});
describe('Upsert Data Merging Logic', () => {
it('should merge new data with existing data without losing fields', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
expect(contact.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
const updated = await ContactService.upsert(projectId, email, {
lastName: 'Doe',
company: 'Acme Inc',
});
expect(updated.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
lastName: 'Doe',
company: 'Acme Inc',
});
});
it('should overwrite existing fields with new values', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {
plan: 'free',
credits: 100,
});
const updated = await ContactService.upsert(projectId, email, {
plan: 'pro',
credits: 1000,
});
expect(updated.data).toMatchObject({
plan: 'pro',
credits: 1000,
});
});
it('should NOT persist non-persistent data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
tempToken: {value: 'abc123', persistent: false},
oneTimeCode: {value: '123456', persistent: false},
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('tempToken');
expect(contact.data).not.toHaveProperty('oneTimeCode');
});
it('should ignore reserved fields (plunk_id, plunk_email)', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plunk_id: 'malicious-id',
plunk_email: 'hacker@evil.com',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('plunk_id');
expect(contact.data).not.toHaveProperty('plunk_email');
});
it('should handle null data gracefully', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, undefined);
expect(contact.email).toBe(email);
});
it('should handle empty object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {});
expect(contact.email).toBe(email);
});
it('should update subscription status independently of data', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {firstName: 'John'}, true);
const subscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(subscribed?.subscribed).toBe(true);
await ContactService.upsert(projectId, email, {lastName: 'Doe'}, false);
const unsubscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(unsubscribed?.subscribed).toBe(false);
expect(unsubscribed?.data).toMatchObject({
firstName: 'John',
lastName: 'Doe',
});
});
});
describe('getMergedData - Template Rendering', () => {
it('should include reserved plunk_id and plunk_email fields', async () => {
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
const merged = ContactService.getMergedData(contact);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('test@example.com');
});
it('should merge persistent contact data', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John', plan: 'pro'},
});
const merged = ContactService.getMergedData(contact);
expect(merged.firstName).toBe('John');
expect(merged.plan).toBe('pro');
});
it('should merge temporary (non-persistent) data for rendering', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John'},
});
const temporaryData = {
resetToken: {value: 'temp123', persistent: false},
resetUrl: {value: 'https://app.com/reset?token=temp123', persistent: false},
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.firstName).toBe('John');
expect(merged.resetToken).toBe('temp123');
expect(merged.resetUrl).toBe('https://app.com/reset?token=temp123');
});
it('should override persistent data with temporary data', async () => {
const contact = await factories.createContact({
projectId,
data: {name: 'Stored Name'},
});
const temporaryData = {
name: 'Override Name',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.name).toBe('Override Name');
});
it('should not allow overriding reserved fields via temporary data', async () => {
const contact = await factories.createContact({
projectId,
email: 'real@example.com',
});
const temporaryData = {
plunk_id: 'fake-id',
plunk_email: 'fake@example.com',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('real@example.com');
});
});
describe('Data Integrity Edge Cases', () => {
it('should handle deeply nested object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
expect(contact.data).toMatchObject({
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
});
it('should handle array data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
tags: ['vip', 'beta-tester', 'early-adopter'],
});
expect(contact.data).toMatchObject({
tags: ['vip', 'beta-tester', 'early-adopter'],
});
});
it('should handle special characters in field names', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
'custom-field': 'value',
'field.with.dots': 'value2',
'field with spaces': 'value3',
});
expect(contact.data).toHaveProperty('custom-field', 'value');
expect(contact.data).toHaveProperty('field.with.dots', 'value2');
expect(contact.data).toHaveProperty('field with spaces', 'value3');
});
it('should handle boolean, number, and string values', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
expect(contact.data).toMatchObject({
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
});
it('should handle null values in data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: null,
lastName: 'Doe',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('middleName', null);
expect(contact.data).toHaveProperty('lastName', 'Doe');
});
});
describe('Contact CRUD Operations', () => {
it('should find contact by email', async () => {
const email = 'find@example.com';
const created = await ContactService.create(projectId, {email});
const found = await ContactService.findByEmail(projectId, email);
expect(found?.id).toBe(created.id);
expect(found?.email).toBe(email);
});
it('should return null when contact not found by email', async () => {
const found = await ContactService.findByEmail(projectId, 'nonexistent@example.com');
expect(found).toBeNull();
});
it('should get contact count for project', async () => {
await factories.createContact({projectId});
await factories.createContact({projectId});
await factories.createContact({projectId});
const count = await ContactService.count(projectId);
expect(count).toBe(3);
});
it('should delete contact', async () => {
const contact = await factories.createContact({projectId});
await ContactService.delete(projectId, contact.id);
const deleted = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(deleted).toBeNull();
});
it('should throw 404 when deleting non-existent contact', async () => {
await expect(ContactService.delete(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
it('should throw 404 when getting non-existent contact', async () => {
await expect(ContactService.get(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
});
describe('Public Contact Operations (Unsubscribe)', () => {
it('should get contact by ID without project authentication', async () => {
const contact = await factories.createContact({projectId});
const fetched = await ContactService.getById(contact.id);
expect(fetched.id).toBe(contact.id);
expect(fetched.email).toBe(contact.email);
});
it('should subscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false,
});
await ContactService.subscribe(contact.id);
const subscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(subscribed?.subscribed).toBe(true);
});
it('should unsubscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
await ContactService.unsubscribe(contact.id);
const unsubscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribed?.subscribed).toBe(false);
});
});
describe('Bulk Contact Operations', () => {
describe('bulkSubscribe', () => {
it('should subscribe multiple unsubscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const contact3 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => c.subscribed)).toBe(true);
});
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [
unsubscribed1.id,
unsubscribed2.id,
alreadySubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: false});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(true);
expect(otherContactAfter?.subscribed).toBe(false);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkSubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
);
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkSubscribe(projectId, contactIds);
expect(result.updated).toBe(150);
const updatedContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
});
});
describe('bulkUnsubscribe', () => {
it('should unsubscribe multiple subscribed contacts', async () => {
const contact1 = await factories.createContact({projectId, subscribed: true});
const contact2 = await factories.createContact({projectId, subscribed: true});
const contact3 = await factories.createContact({projectId, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.updated).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts.every(c => !c.subscribed)).toBe(true);
});
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
const subscribed1 = await factories.createContact({projectId, subscribed: true});
const subscribed2 = await factories.createContact({projectId, subscribed: true});
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [
subscribed1.id,
subscribed2.id,
alreadyUnsubscribed.id,
]);
expect(result.updated).toBe(2);
});
it('should return 0 if no contacts need updating', async () => {
const contact1 = await factories.createContact({projectId, subscribed: false});
const contact2 = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
expect(result.updated).toBe(0);
});
it('should only update contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId, subscribed: true});
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
expect(result.updated).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter?.subscribed).toBe(false);
expect(otherContactAfter?.subscribed).toBe(true);
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, []);
expect(result.updated).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.updated).toBe(0);
});
});
describe('bulkDelete', () => {
it('should delete multiple contacts', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
const contact3 = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
expect(result.deleted).toBe(3);
const contacts = await prisma.contact.findMany({
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
});
expect(contacts).toHaveLength(0);
});
it('should only delete contacts belonging to the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const ownContact = await factories.createContact({projectId});
const otherContact = await factories.createContact({projectId: otherProject.id});
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
expect(result.deleted).toBe(1);
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
expect(ownContactAfter).toBeNull();
expect(otherContactAfter).not.toBeNull();
});
it('should handle empty contact IDs array', async () => {
const result = await ContactService.bulkDelete(projectId, []);
expect(result.deleted).toBe(0);
});
it('should handle non-existent contact IDs gracefully', async () => {
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
expect(result.deleted).toBe(0);
});
it('should handle large batches efficiently', async () => {
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
const contactIds = contacts.map(c => c.id);
const result = await ContactService.bulkDelete(projectId, contactIds);
expect(result.deleted).toBe(200);
const remainingContacts = await prisma.contact.findMany({
where: {id: {in: contactIds}},
});
expect(remainingContacts).toHaveLength(0);
});
it('should delete both subscribed and unsubscribed contacts', async () => {
const subscribed = await factories.createContact({projectId, subscribed: true});
const unsubscribed = await factories.createContact({projectId, subscribed: false});
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
expect(result.deleted).toBe(2);
});
it('should handle partial matches (some exist, some do not)', async () => {
const existingContact = await factories.createContact({projectId});
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
expect(result.deleted).toBe(1);
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
expect(contact).toBeNull();
});
});
describe('Bulk Operations - Project Isolation', () => {
it('should never leak contacts between projects in bulk operations', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
const p1ContactsAfter = await prisma.contact.findMany({
where: {projectId: project1.id},
});
const p2ContactsAfter = await prisma.contact.findMany({
where: {projectId: project2.id},
});
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
});
});
});
});
@@ -0,0 +1,546 @@
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';
import * as SESService from '../SESService.js';
/**
* Unit tests for DomainService
* Focuses on business logic, validation, and edge cases
*/
describe('DomainService', () => {
const prisma = getPrismaClient();
beforeEach(() => {
// Mock SES service calls to avoid AWS API calls
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
status: 'Success',
tokens: ['token1', 'token2', 'token3'],
});
});
// ========================================
// ADD DOMAIN
// ========================================
describe('addDomain', () => {
it('should add a domain and initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'example.com';
const result = await DomainService.addDomain(project.id, domain);
expect(result.domain).toBe(domain);
expect(result.projectId).toBe(project.id);
expect(result.verified).toBe(false);
expect(result.dkimTokens).toEqual(['token1', 'token2', 'token3']);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
it('should call AWS SES to initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'test-domain.com';
await DomainService.addDomain(project.id, domain);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
});
// ========================================
// CHECK DOMAIN OWNERSHIP
// ========================================
describe('checkDomainOwnership', () => {
it('should return exists: false for non-existent domain', async () => {
const result = await DomainService.checkDomainOwnership('non-existent.com', 'user-id');
expect(result).toEqual({exists: false});
});
it('should return project info when domain exists', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'existing.com');
const result = await DomainService.checkDomainOwnership('existing.com', user.id);
expect(result.exists).toBe(true);
expect(result.projectId).toBe(project.id);
expect(result.projectName).toBe(project.name);
expect(result.isMember).toBe(true);
});
it('should indicate isMember: false when user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
await DomainService.addDomain(project1.id, 'restricted.com');
const result = await DomainService.checkDomainOwnership('restricted.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(false);
});
it('should indicate isMember: true when user is a member', async () => {
const {project} = await factories.createUserWithProject();
const user2 = await factories.createUser();
await prisma.membership.create({
data: {
userId: user2.id,
projectId: project.id,
role: 'MEMBER',
},
});
await DomainService.addDomain(project.id, 'team-domain.com');
const result = await DomainService.checkDomainOwnership('team-domain.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(true);
});
});
// ========================================
// VERIFY EMAIL DOMAIN
// ========================================
describe('verifyEmailDomain', () => {
it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
HttpException,
);
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should throw error when domain belongs to different project', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project1.id, 'project1.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(
/belongs to a different project/i,
);
});
it('should throw error when domain is not verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified.com');
await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(HttpException);
await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(
/not verified/i,
);
});
it('should return domain when all checks pass', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'verified.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@verified.com', project.id);
expect(result.domain).toBe('verified.com');
expect(result.verified).toBe(true);
expect(result.projectId).toBe(project.id);
});
it('should extract domain correctly from various email formats', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Test various email formats
const result1 = await DomainService.verifyEmailDomain('user@example.com', project.id);
const result2 = await DomainService.verifyEmailDomain('admin@example.com', project.id);
const result3 = await DomainService.verifyEmailDomain('support+tag@example.com', project.id);
expect(result1.domain).toBe('example.com');
expect(result2.domain).toBe('example.com');
expect(result3.domain).toBe('example.com');
});
});
// ========================================
// GET DOMAIN BY ID
// ========================================
describe('id', () => {
it('should return domain by id', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'test.com');
const result = await DomainService.id(domain.id);
expect(result).not.toBeNull();
expect(result?.id).toBe(domain.id);
expect(result?.domain).toBe('test.com');
});
it('should return null for non-existent id', async () => {
const result = await DomainService.id('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
});
});
// ========================================
// GET PROJECT DOMAINS
// ========================================
describe('getProjectDomains', () => {
it('should return all domains for a project', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'domain1.com');
await DomainService.addDomain(project.id, 'domain2.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(2);
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com']);
});
it('should return domains ordered by creation date (newest first)', async () => {
const {project} = await factories.createUserWithProject();
// Add domains with slight delay to ensure different timestamps
const domain1 = await DomainService.addDomain(project.id, 'first.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain2 = await DomainService.addDomain(project.id, 'second.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain3 = await DomainService.addDomain(project.id, 'third.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains[0].id).toBe(domain3.id); // Newest first
expect(domains[1].id).toBe(domain2.id);
expect(domains[2].id).toBe(domain1.id);
});
it('should return empty array for project with no domains', async () => {
const {project} = await factories.createUserWithProject();
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toEqual([]);
});
});
// ========================================
// GET VERIFIED DOMAINS
// ========================================
describe('getVerifiedDomains', () => {
it('should return only verified domains', async () => {
const {project} = await factories.createUserWithProject();
const domain1 = await DomainService.addDomain(project.id, 'verified1.com');
await DomainService.addDomain(project.id, 'unverified.com');
const domain3 = await DomainService.addDomain(project.id, 'verified2.com');
// Mark two as verified
await prisma.domain.update({
where: {id: domain1.id},
data: {verified: true},
});
await prisma.domain.update({
where: {id: domain3.id},
data: {verified: true},
});
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toHaveLength(2);
expect(verifiedDomains.map(d => d.domain).sort()).toEqual(['verified1.com', 'verified2.com']);
});
it('should return empty array when no domains are verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified1.com');
await DomainService.addDomain(project.id, 'unverified2.com');
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toEqual([]);
});
});
// ========================================
// CHECK VERIFICATION
// ========================================
describe('checkVerification', () => {
it('should check verification status with AWS SES', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'check-verification.com');
const result = await DomainService.checkVerification(domain.id);
expect(result.domain).toBe('check-verification.com');
expect(result.tokens).toEqual(['token1', 'token2', 'token3']);
expect(result.status).toBe('Success');
expect(result.verified).toBe(true);
expect(SESService.getDomainVerificationAttributes).toHaveBeenCalledWith('check-verification.com');
});
it('should update domain to verified when SES returns Success', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'newly-verified.com');
expect(domain.verified).toBe(false);
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(true);
});
it('should update domain to unverified when SES returns Pending', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'pending-domain.com');
// Manually mark as verified
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Mock SES to return Pending
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValueOnce({
status: 'Pending',
tokens: ['token1', 'token2', 'token3'],
});
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(false);
});
it('should throw error for non-existent domain', async () => {
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
});
// ========================================
// REMOVE DOMAIN
// ========================================
describe('removeDomain', () => {
it('should remove domain when not in use', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'remove-me.com');
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error when domain is used in templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-template.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender@used-in-template.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
});
it('should throw error when domain is used in active campaigns', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@used-in-campaign.com',
status: 'DRAFT',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
});
it('should allow removal when campaign is SENT (completed)', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'completed-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@completed-campaign.com',
status: 'SENT',
});
// Should not throw
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error for non-existent domain', async () => {
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
/domain not found/i,
);
});
it('should check usage in multiple templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'multi-use.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender1@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender2@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender3@multi-use.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/3 template/i);
});
});
// ========================================
// EDGE CASES AND ERROR HANDLING
// ========================================
describe('Edge Cases', () => {
it('should handle emails with plus addressing', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('user+tag@example.com', project.id);
expect(result.domain).toBe('example.com');
});
it('should handle subdomain correctly', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'mail.example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@mail.example.com', project.id);
expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail
await expect(DomainService.verifyEmailDomain('sender@other.example.com', project.id)).rejects.toThrow(
/not registered/i,
);
});
it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
});
it('should handle email with multiple @ signs', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('user@@example.com', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
});
});
// ========================================
// CONCURRENCY AND RACE CONDITIONS
// ========================================
describe('Concurrency', () => {
it('should handle concurrent domain additions to same project', async () => {
const {project} = await factories.createUserWithProject();
// Add multiple domains concurrently
const results = await Promise.all([
DomainService.addDomain(project.id, 'concurrent1.com'),
DomainService.addDomain(project.id, 'concurrent2.com'),
DomainService.addDomain(project.id, 'concurrent3.com'),
]);
expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
});
it('should handle concurrent ownership checks', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'concurrent-check.com');
// Multiple concurrent ownership checks
const results = await Promise.all([
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
]);
// All should return consistent results
expect(results.every(r => r.exists)).toBe(true);
expect(results.every(r => r.isMember)).toBe(true);
});
});
});
File diff suppressed because it is too large Load Diff

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