Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75aed81e1 | ||
|
|
0cbaa8fc66 | ||
|
|
0686b3a3f2 | ||
|
|
9b15b93b96 | ||
|
|
8f2510f5ec | ||
|
|
e196d798c5 | ||
|
|
777a12ec89 | ||
|
|
6c6af775bc | ||
|
|
4f8c9f029d | ||
|
|
8f72994b46 | ||
|
|
d162f255c2 | ||
|
|
c8e252fefd | ||
|
|
b8f1ad9ab5 | ||
|
|
708ab506f1 | ||
|
|
ec1f4c9374 | ||
|
|
5528288c7b | ||
|
|
7bd098bdd0 | ||
|
|
cbea263a91 | ||
|
|
e8a247fe12 | ||
|
|
4b51e386e3 | ||
|
|
59aa7845ba | ||
|
|
5600c49bb6 | ||
|
|
21af8fe05e | ||
|
|
2c4d95e604 | ||
|
|
64bd094b47 | ||
|
|
53b33bbd36 | ||
|
|
5fa9b9dda5 | ||
|
|
4bc5424815 | ||
|
|
573e2e8449 | ||
|
|
00ae942c3c | ||
|
|
9c69c7a36e | ||
|
|
9568b7d345 | ||
|
|
6bba9b6199 | ||
|
|
2f55d32485 | ||
|
|
eec37ecb31 | ||
|
|
f947ee6f22 | ||
|
|
3330d83d08 | ||
|
|
de7ed84fcf | ||
|
|
e639c72e8b | ||
|
|
43ea660d52 | ||
|
|
1f85d448e2 | ||
|
|
e7f168c373 | ||
|
|
b42698616a | ||
|
|
a00473314f | ||
|
|
2dabcb2419 | ||
|
|
d9ae171cc7 | ||
|
|
e4e334c77a | ||
|
|
78d3d224af | ||
|
|
f9b1354460 | ||
|
|
95e5ec8485 | ||
|
|
d050b55baa | ||
|
|
7964563b62 | ||
|
|
e01dc4066c | ||
|
|
c286f49097 | ||
|
|
4c81d9ec04 | ||
|
|
a928666dfc | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
d55dda3127 | ||
|
|
d637147f25 | ||
|
|
c1ed23c967 | ||
|
|
46001619e8 | ||
|
|
7a6df7760b | ||
|
|
d2d779c68f | ||
|
|
bbc12e0029 | ||
|
|
125718bb94 | ||
|
|
35d67c6b42 | ||
|
|
14c471e4ca | ||
|
|
42ceb6edc0 | ||
|
|
5bce1d74ff | ||
|
|
b4404f698e | ||
|
|
e4625894c9 | ||
|
|
af87d80baa | ||
|
|
1f84c9113c | ||
|
|
0cf0a26a97 | ||
|
|
0d4b694208 | ||
|
|
c40394ffd6 | ||
|
|
20ffb2b8bc | ||
|
|
0af79eb84b | ||
|
|
e2e98149cb | ||
|
|
f55ab31ae5 | ||
|
|
61a65d01c7 | ||
|
|
7aa3b74c6f | ||
|
|
7647109fb6 | ||
|
|
ad0c17da56 | ||
|
|
93ca6b8d94 | ||
|
|
c85df75d53 | ||
|
|
e16093a264 | ||
|
|
a54c8139b1 | ||
|
|
8de79e37e9 | ||
|
|
c662ecdbb2 | ||
|
|
8f6f63e572 | ||
|
|
22c22aba1b | ||
|
|
de106f9d91 | ||
|
|
8a38766c02 | ||
|
|
3a16ad3221 | ||
|
|
ec402cb9b3 | ||
|
|
663bc2be8d | ||
|
|
df28e2dc7d | ||
|
|
79fe9e94c7 | ||
|
|
c2afb2dfab | ||
|
|
0d1ed0ec67 | ||
|
|
99526ebfc2 | ||
|
|
e6baace00d | ||
|
|
743155b06a | ||
|
|
0a67a8278f | ||
|
|
26800a8553 | ||
|
|
93165644af | ||
|
|
4db1ccc3fc | ||
|
|
06c4d644b7 | ||
|
|
b5fa21a57c | ||
|
|
ddbe5237fe | ||
|
|
18e17cf198 | ||
|
|
6a6772d5ed | ||
|
|
50042cea27 | ||
|
|
3ccf890a21 | ||
|
|
d57f6c81cb | ||
|
|
c07816c2a1 | ||
|
|
73da0a5136 | ||
|
|
d12b797ebe | ||
|
|
7df43d8553 | ||
|
|
b1047590e6 | ||
|
|
e3cf0fe0c1 | ||
|
|
940c8938f1 | ||
|
|
e75e07f73f | ||
|
|
940a4d225b | ||
|
|
7386441e61 | ||
|
|
718251c67c | ||
|
|
b6c5471d27 | ||
|
|
fa22b8220a | ||
|
|
a5c575444b | ||
|
|
4dce71a1fe | ||
|
|
ee00eb3481 | ||
|
|
5d44b1606d | ||
|
|
e732c76490 | ||
|
|
fb02051538 | ||
|
|
6f3853de59 | ||
|
|
8790c45edc | ||
|
|
49824aff93 | ||
|
|
492beb095f | ||
|
|
d11c4af4d1 | ||
|
|
82a7c8490e | ||
|
|
457c829b2d | ||
|
|
da7f3e5718 | ||
|
|
76786b2eae | ||
|
|
3a299cae98 | ||
|
|
dd4737cfe9 | ||
|
|
02416654b6 | ||
|
|
85c992a9f9 | ||
|
|
38da58e5e9 | ||
|
|
519b131792 | ||
|
|
be2eb57369 | ||
|
|
19554e6e8f | ||
|
|
68b7e7c082 | ||
|
|
7648f6e1eb | ||
|
|
289c633189 | ||
|
|
857bb8ac9e |
+19
-4
@@ -27,10 +27,10 @@ WIKI_URI=http://localhost:1000
|
||||
|
||||
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://docs.useplunk.com
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
# This affects how application URIs are auto-generated from domain names
|
||||
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
|
||||
# Default: true
|
||||
# AUTO_PROJECT_DISABLE=false
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Self-Hosting User Management
|
||||
# ========================================
|
||||
# Controls whether new user signups are allowed
|
||||
# When enabled (true), the signup endpoint will reject new user registration attempts
|
||||
# Useful for private instances or when you want to manually manage users
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Controls whether email validation checks are performed on signup
|
||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||
# When disabled (false), skips these validation checks and allows any email format
|
||||
# Default: false
|
||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||
|
||||
# ========================================
|
||||
# ADVANCED (rarely needed)
|
||||
# ========================================
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Feature requests
|
||||
about: Use this template to suggest new features for Plunk
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem?
|
||||
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Alternatives or workarounds
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -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
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: yarn workspace @plunk/db db:generate
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Run linter
|
||||
run: yarn lint
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.3.0"
|
||||
".": "0.7.0"
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ nodeLinker: node-modules
|
||||
|
||||
# Support multiple architectures - download pre-built binaries instead of compiling
|
||||
supportedArchitectures:
|
||||
os: [ "linux", "darwin" ]
|
||||
os: [ "linux", "darwin", "win32" ]
|
||||
cpu: [ "x64", "arm64" ]
|
||||
libc: [ "glibc" ]
|
||||
|
||||
|
||||
+126
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||
|
||||
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||
|
||||
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
|
||||
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
|
||||
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
|
||||
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
|
||||
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
|
||||
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
|
||||
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
|
||||
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
|
||||
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
|
||||
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
|
||||
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
|
||||
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
|
||||
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
|
||||
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
|
||||
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
|
||||
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
|
||||
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
|
||||
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
|
||||
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
|
||||
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
|
||||
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
|
||||
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
|
||||
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
|
||||
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
|
||||
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
|
||||
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
|
||||
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
|
||||
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
|
||||
|
||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
|
||||
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
|
||||
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
|
||||
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
|
||||
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
|
||||
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
|
||||
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
|
||||
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
|
||||
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
|
||||
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
|
||||
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
|
||||
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
|
||||
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
|
||||
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
|
||||
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
|
||||
|
||||
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
|
||||
|
||||
|
||||
|
||||
@@ -64,12 +64,12 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
||||
### Applications (`apps/`)
|
||||
|
||||
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
||||
- HTTP API endpoints for the platform
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
- HTTP API endpoints for the platform
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
@@ -108,6 +108,7 @@ between groups.
|
||||
- Consistent type imports preferred: `import type { ... }`
|
||||
- Unused vars allowed with `_` prefix
|
||||
- Strict type checking enabled across all packages
|
||||
- Try to avoid inline types in favor of shared types in `@plunk/types`
|
||||
|
||||
### Component Structure
|
||||
|
||||
@@ -147,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
|
||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||
- Self-hosting User Management (optional):
|
||||
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
|
||||
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
|
||||
plus-addressing, domain existence, and MX records
|
||||
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
|
||||
bounce/complaint rate thresholds are exceeded
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
@@ -155,4 +162,11 @@ Required for builds and deployment (see turbo.json and .env.example):
|
||||
allows the same Docker image to be used across different environments by simply changing environment variables at
|
||||
runtime
|
||||
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
|
||||
client-side access
|
||||
client-side access
|
||||
|
||||
## Plugins
|
||||
|
||||
There are two plugins installed for you to use.
|
||||
|
||||
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
||||
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
|
||||
|
||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
@@ -135,7 +135,7 @@ between groups.
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
|
||||
+5
-6
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
|
||||
# Build-time arguments for URL configuration
|
||||
# These are only used during the build process (for wiki OpenAPI generation and static assets)
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://app.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://next.useplunk.com
|
||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -273,8 +273,7 @@ COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/sh
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
|
||||
# @plunk/types exports source TypeScript files directly (no build output)
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
|
||||
|
||||
# Copy Prisma schema (needed for migrations at runtime)
|
||||
|
||||
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
||||
USE_HTTPS=false
|
||||
|
||||
API_URI=http://localhost:8080
|
||||
WIKI_URI=http://localhost:1000
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailchecker": "^6.0.19",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.2",
|
||||
"multer": "^2.1.1",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
NotAuthenticated,
|
||||
NotFound,
|
||||
RateLimitError,
|
||||
ValidationError
|
||||
ValidationError,
|
||||
} from '../../exceptions/index.js';
|
||||
import {EmailService} from '../../services/EmailService.js';
|
||||
|
||||
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
|
||||
expect(result.data.subscribed).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
|
||||
const newEmail = '[email protected]';
|
||||
|
||||
// Send email to new contact without specifying subscribed
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
|
||||
|
||||
// Transactional emails should create contacts as unsubscribed by default
|
||||
expect(contact.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/v1/track endpoint', () => {
|
||||
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
|
||||
|
||||
// Track event for new contact without specifying subscribed
|
||||
const {ContactService} = await import('../../services/ContactService.js');
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
|
||||
|
||||
// New contacts should default to subscribed=true
|
||||
// Event tracking should create contacts as subscribed by default
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
|
||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Note: To run these integration tests, you need to:
|
||||
|
||||
+88
-10
@@ -20,7 +20,7 @@ import {
|
||||
SMTP_ENABLED,
|
||||
STRIPE_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
WIKI_URI
|
||||
WIKI_URI,
|
||||
} from './app/constants.js';
|
||||
import {Actions} from './controllers/Actions.js';
|
||||
import {Activity} from './controllers/Activity.js';
|
||||
@@ -76,23 +76,73 @@ const server = new (class extends Server {
|
||||
// Log all requests to database for historical tracking and analytics
|
||||
this.app.use(databaseRequestLogger);
|
||||
|
||||
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
|
||||
res.set({'Access-Control-Allow-Origin': '*'});
|
||||
next();
|
||||
});
|
||||
|
||||
// Build allowed origins from environment variables
|
||||
const allowedOrigins =
|
||||
NODE_ENV === 'development'
|
||||
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
||||
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
||||
|
||||
this.app.use(
|
||||
// Public API endpoints that should allow all origins
|
||||
const publicApiPaths = ['/v1', '/v1/track', '/v1/send'];
|
||||
|
||||
// Log CORS configuration on startup
|
||||
signale.info('CORS configuration', {
|
||||
environment: NODE_ENV,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
publicApiPaths,
|
||||
});
|
||||
|
||||
// Apply restrictive CORS to all routes EXCEPT public API endpoints
|
||||
this.app.use((req, res, next) => {
|
||||
// Check if this is a public API endpoint
|
||||
const isPublicApi = publicApiPaths.some(path => req.path === path || req.path.startsWith(path + '/'));
|
||||
|
||||
if (isPublicApi) {
|
||||
// For public API endpoints, allow all origins
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
});
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// For other endpoints, apply restrictive CORS
|
||||
cors({
|
||||
origin: allowedOrigins,
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (e.g., mobile apps, curl, server-to-server)
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check if origin matches any allowed origin (string or regex)
|
||||
const isAllowed = allowedOrigins.some(allowed => {
|
||||
if (allowed instanceof RegExp) {
|
||||
return allowed.test(origin);
|
||||
}
|
||||
return allowed === origin;
|
||||
});
|
||||
|
||||
if (isAllowed) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Log CORS rejection with helpful information
|
||||
signale.warn('CORS request rejected', {
|
||||
origin,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
hint: 'If using HTTPS, ensure USE_HTTPS=true is set in your environment variables',
|
||||
});
|
||||
// Reject the CORS request by passing false (don't send CORS headers)
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
||||
|
||||
@@ -154,6 +204,34 @@ interface ErrorResponse {
|
||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const requestId = res.locals.requestId as string | undefined;
|
||||
|
||||
// Handle JSON parsing errors (from express.json() middleware)
|
||||
if (error instanceof SyntaxError && 'body' in error) {
|
||||
const statusCode = 400;
|
||||
|
||||
logger.warn(
|
||||
'JSON parsing failed',
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
contentType: req.get('content-type'),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: 'Invalid JSON in request body',
|
||||
statusCode,
|
||||
requestId,
|
||||
suggestion: 'Ensure your request body is valid JSON and Content-Type header is set to "application/json".',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
||||
|
||||
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
||||
// Useful for self-hosters who want to manage project status manually
|
||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||
|
||||
// Self-hosting Configuration (optional)
|
||||
// Controls whether new user signups are allowed (default: false)
|
||||
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
|
||||
// Controls whether email validation checks are performed on signup (default: false)
|
||||
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
|
||||
|
||||
// Email Verification & Password Reset
|
||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
@@ -52,7 +50,7 @@ export class Actions {
|
||||
@Middleware([requirePublicKey])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||
@@ -74,6 +72,8 @@ export class Actions {
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||
// but preserve existing subscription state for existing contacts
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
email,
|
||||
@@ -173,7 +173,7 @@ export class Actions {
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
||||
@@ -268,7 +268,10 @@ export class Actions {
|
||||
: (data as Record<string, unknown> | undefined);
|
||||
|
||||
// Create or update contact with metadata
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
|
||||
// Transactional emails should not subscribe contacts by default
|
||||
// New contacts default to unsubscribed unless explicitly opted in
|
||||
// Existing contacts preserve their subscription state unless explicitly changed
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
|
||||
|
||||
// Get merged data including non-persistent fields for template rendering
|
||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||
@@ -277,6 +280,7 @@ export class Actions {
|
||||
// These are always available for template rendering
|
||||
const dataWithSystemVars = {
|
||||
...mergedData,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ActivityService, ActivityType} from '../services/ActivityService.js';
|
||||
import {ActivityService} from '../services/ActivityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('activity')
|
||||
@@ -24,7 +23,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getActivities(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const contactId = req.query.contactId as string | undefined;
|
||||
@@ -65,7 +64,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -85,7 +84,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
|
||||
|
||||
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
|
||||
@@ -117,7 +116,7 @@ export class Activity {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {AnalyticsService} from '../services/AnalyticsService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -22,7 +20,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -44,7 +42,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
@@ -68,7 +66,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -92,7 +90,7 @@ export class Analytics {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as React from 'react';
|
||||
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
@@ -15,12 +16,14 @@ import {
|
||||
PASSWORD_RESET_RATE_LIMIT,
|
||||
PLUNK_ENABLED,
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
VERIFY_EMAIL_ON_SIGNUP,
|
||||
} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
||||
import {jwt, parseJwt} from '../middleware/auth.js';
|
||||
import {AuthService} from '../services/AuthService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
@@ -62,8 +65,43 @@ export class Auth {
|
||||
@Post('signup')
|
||||
@CatchAsync
|
||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
return res.json({
|
||||
success: false,
|
||||
data: 'New user signups are currently disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||
|
||||
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
|
||||
if (VERIFY_EMAIL_ON_SIGNUP) {
|
||||
const verification = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
if (
|
||||
verification.isDisposable ||
|
||||
verification.isPlusAddressed ||
|
||||
!verification.domainExists ||
|
||||
!verification.hasMxRecords
|
||||
) {
|
||||
// Build list of reasons for notification
|
||||
const reasons: string[] = [];
|
||||
if (verification.isDisposable) reasons.push('disposable email');
|
||||
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
||||
if (!verification.domainExists) reasons.push('domain does not exist');
|
||||
if (!verification.hasMxRecords) reasons.push('no MX records');
|
||||
|
||||
// Send notification about failed signup attempt
|
||||
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
data: 'This email address cannot be used for signup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
@@ -20,11 +19,10 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
CampaignSchemas.create.parse(req.body);
|
||||
|
||||
// Validate audience-specific fields
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
@@ -63,7 +61,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const status = req.query.status as CampaignStatus | undefined;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
@@ -79,13 +77,7 @@ export class Campaigns {
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
campaigns: result.campaigns,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
total: result.total,
|
||||
totalPages: result.totalPages,
|
||||
});
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +88,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||
@@ -115,7 +107,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||
req.body;
|
||||
@@ -161,7 +153,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
await CampaignService.delete(auth.projectId, id!);
|
||||
@@ -180,7 +172,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||
@@ -200,7 +192,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const scheduledFor = req.body?.scheduledFor;
|
||||
|
||||
@@ -231,7 +223,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||
@@ -251,7 +243,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||
@@ -270,7 +262,7 @@ export class Campaigns {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
|
||||
|
||||
import {
|
||||
API_URI,
|
||||
AWS_SES_REGION,
|
||||
DASHBOARD_URI,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
@@ -59,6 +60,9 @@ export class Config {
|
||||
: null,
|
||||
},
|
||||
},
|
||||
aws: {
|
||||
sesRegion: AWS_SES_REGION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {QueueService} from '../services/QueueService.js';
|
||||
@@ -16,7 +14,6 @@ const upload = multer({
|
||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// Only accept CSV files
|
||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
@@ -35,7 +32,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -54,7 +51,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
|
||||
@@ -80,7 +77,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||
|
||||
@@ -113,7 +110,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
@@ -133,7 +130,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
if (!email) {
|
||||
@@ -163,7 +160,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
@@ -184,7 +181,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
@@ -284,7 +281,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, upload.single('file')])
|
||||
@CatchAsync
|
||||
public async importCsv(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({error: 'CSV file is required'});
|
||||
@@ -318,6 +315,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
@@ -325,7 +323,7 @@ export class Contacts {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getImportJobStatus(jobId);
|
||||
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Import job not found'});
|
||||
@@ -349,7 +347,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
@@ -376,7 +374,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteField(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
@@ -402,7 +400,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
@@ -437,7 +435,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
@@ -471,7 +469,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
@@ -505,6 +503,7 @@ export class Contacts {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
@@ -512,7 +511,7 @@ export class Contacts {
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId);
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Bulk action job not found'});
|
||||
|
||||
@@ -4,11 +4,10 @@ import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('domains')
|
||||
@@ -20,20 +19,11 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
|
||||
@@ -47,7 +37,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -55,19 +45,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
// Check if domain is already linked to another project
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||
@@ -107,7 +85,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
@@ -117,16 +95,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has access to the project this domain belongs to
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: domain.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||
|
||||
const verificationStatus = await DomainService.checkVerification(id);
|
||||
|
||||
@@ -144,7 +113,7 @@ export class Domains {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
@@ -154,19 +123,7 @@ export class Domains {
|
||||
}
|
||||
|
||||
// Verify user has admin access to the project this domain belongs to
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: domain.projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Domain not found or you do not have permission');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
|
||||
await DomainService.removeDomain(id);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -17,7 +15,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, contactId, emailId, data} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -37,7 +35,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
|
||||
@@ -54,7 +52,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
@@ -71,7 +69,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.contactId;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
@@ -92,7 +90,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
|
||||
|
||||
@@ -108,7 +106,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
@@ -135,7 +133,7 @@ export class Events {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
|
||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
||||
import {
|
||||
API_URI,
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
GITHUB_OAUTH_CLIENT,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GITHUB_OAUTH_SECRET,
|
||||
} from '../../app/constants.js';
|
||||
import {prisma} from '../../database/prisma.js';
|
||||
import {BadRequest} from '../../exceptions/index.js';
|
||||
import {jwt} from '../../middleware/auth.js';
|
||||
import {NtfyService} from '../../services/NtfyService.js';
|
||||
import {UserService} from '../../services/UserService.js';
|
||||
@@ -81,6 +83,11 @@ export class Github {
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
|
||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
||||
import {
|
||||
API_URI,
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
GOOGLE_OAUTH_CLIENT,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_SECRET,
|
||||
} from '../../app/constants.js';
|
||||
import {prisma} from '../../database/prisma.js';
|
||||
import {BadRequest} from '../../exceptions/index.js';
|
||||
import {jwt} from '../../middleware/auth.js';
|
||||
import {NtfyService} from '../../services/NtfyService.js';
|
||||
import {UserService} from '../../services/UserService.js';
|
||||
@@ -71,6 +73,11 @@ export class Google {
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
|
||||
@@ -4,8 +4,8 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -19,20 +19,11 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get project with relevant data
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -92,20 +83,11 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Use existing SecurityService
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -124,43 +106,18 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get all members of the project
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {
|
||||
projectId: id,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const members = await MembershipService.getMembers(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: members.map(m => ({
|
||||
userId: m.user.id,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
})),
|
||||
data: members,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,7 +130,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Validate params
|
||||
@@ -190,19 +147,7 @@ export class Projects {
|
||||
const {email, role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can add members');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Find user by email
|
||||
const userToAdd = await prisma.user.findUnique({
|
||||
@@ -214,28 +159,8 @@ export class Projects {
|
||||
throw new HttpException(404, 'User with this email does not have an account');
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId: userToAdd.id,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new HttpException(409, 'User is already a member of this project');
|
||||
}
|
||||
|
||||
// Create membership
|
||||
const newMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: userToAdd.id,
|
||||
projectId: id,
|
||||
role,
|
||||
},
|
||||
});
|
||||
// Add member to project
|
||||
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -256,7 +181,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
@@ -276,38 +201,7 @@ export class Projects {
|
||||
const {role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can update member roles');
|
||||
}
|
||||
|
||||
// Get target membership
|
||||
const targetMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new HttpException(404, 'Member not found');
|
||||
}
|
||||
|
||||
// Cannot change OWNER role
|
||||
if (targetMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -319,16 +213,8 @@ export class Projects {
|
||||
throw new HttpException(404, 'User not found');
|
||||
}
|
||||
|
||||
// Update role
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
data: {role},
|
||||
});
|
||||
// Update role (service handles validation)
|
||||
await MembershipService.updateRole(id, userId, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
@@ -348,7 +234,7 @@ export class Projects {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async removeMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
@@ -360,53 +246,15 @@ export class Projects {
|
||||
}
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
const currentMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentMembership) {
|
||||
throw new HttpException(403, 'Only project admins and owners can remove members');
|
||||
}
|
||||
|
||||
// Get target membership
|
||||
const targetMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new HttpException(404, 'Member not found');
|
||||
}
|
||||
|
||||
// Cannot remove OWNER
|
||||
if (targetMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot remove the project owner');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Cannot remove yourself
|
||||
if (userId === auth.userId) {
|
||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||
}
|
||||
|
||||
// Delete membership
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Remove member (service handles validation)
|
||||
await MembershipService.removeMember(id, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -16,7 +14,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const segments = await SegmentService.list(auth.projectId!);
|
||||
|
||||
@@ -31,7 +29,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -51,7 +49,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContacts(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
@@ -73,21 +71,24 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, type, condition, trackMembership} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object'});
|
||||
const segmentType = type ?? 'DYNAMIC';
|
||||
|
||||
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
condition,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
@@ -102,7 +103,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
|
||||
@@ -132,7 +133,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -144,6 +145,56 @@ export class Segments {
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/members
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /segments/:id/members
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
@Delete(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/compute
|
||||
* Recompute segment membership for all contacts
|
||||
@@ -152,7 +203,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async compute(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
@@ -172,7 +223,7 @@ export class Segments {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async refresh(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {TemplateType} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {TemplateService} from '../services/TemplateService.js';
|
||||
@@ -18,7 +16,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -37,7 +35,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -57,7 +55,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -101,7 +99,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
@@ -136,7 +134,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -156,7 +154,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
@@ -176,7 +174,7 @@ export class Templates {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import * as S3Service from '../services/S3Service.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -15,7 +13,6 @@ const upload = multer({
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
// Only accept image files
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
@@ -36,7 +33,7 @@ export class Uploads {
|
||||
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
|
||||
@CatchAsync
|
||||
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
if (!S3Service.isS3Enabled()) {
|
||||
|
||||
@@ -8,9 +8,9 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
@@ -23,7 +23,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async me(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -42,7 +42,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async meProjects(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -57,7 +57,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
@@ -104,24 +104,12 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const data = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to update it');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Update the project
|
||||
const project = await prisma.project.update({
|
||||
@@ -136,23 +124,11 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to regenerate keys');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
@@ -188,7 +164,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {currency} = req.query;
|
||||
|
||||
@@ -197,20 +173,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -280,7 +244,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -288,20 +252,8 @@ export class Users {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
||||
}
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -330,7 +282,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -342,16 +294,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing limits');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get billing limits and usage
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
@@ -363,7 +306,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -377,19 +320,7 @@ export class Users {
|
||||
const data = BillingLimitSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -399,6 +330,7 @@ export class Users {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,6 +350,7 @@ export class Users {
|
||||
billingLimitWorkflows: data.workflows,
|
||||
billingLimitCampaigns: data.campaigns,
|
||||
billingLimitTransactional: data.transactional,
|
||||
billingLimitInbound: data.inbound,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -429,6 +362,7 @@ export class Users {
|
||||
workflows: project.billingLimitWorkflows,
|
||||
campaigns: project.billingLimitCampaigns,
|
||||
transactional: project.billingLimitTransactional,
|
||||
inbound: project.billingLimitInbound,
|
||||
},
|
||||
data,
|
||||
);
|
||||
@@ -442,7 +376,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -459,16 +393,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
@@ -568,7 +493,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
@@ -585,16 +510,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
@@ -656,7 +572,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -668,16 +584,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to view security metrics');
|
||||
}
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get security metrics
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
@@ -689,7 +596,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -701,19 +608,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
@@ -775,7 +670,7 @@ export class Users {
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
@@ -787,21 +682,7 @@ export class Users {
|
||||
}
|
||||
|
||||
// Verify user has owner or admin access to this project
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: auth.userId,
|
||||
projectId: id,
|
||||
role: {
|
||||
in: ['OWNER', 'ADMIN'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFound(
|
||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
||||
);
|
||||
}
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get project to check for active subscription and disabled status
|
||||
const project = await prisma.project.findUnique({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Controller, Post} from '@overnightjs/core';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailStatus} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
@@ -8,7 +8,10 @@ import type Stripe from 'stripe';
|
||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -21,7 +24,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
export class Webhooks {
|
||||
/**
|
||||
* Receive SNS webhook notifications from AWS SES
|
||||
* Handles email events: delivery, open, click, bounce, complaint
|
||||
* Handles outbound email events: delivery, open, click, bounce, complaint
|
||||
* Handles inbound email notifications: received emails via SES receiving
|
||||
*/
|
||||
@Post('sns')
|
||||
@CatchAsync
|
||||
@@ -30,11 +34,35 @@ export class Webhooks {
|
||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||
signale.info('SNS Subscription Confirmation received');
|
||||
signale.info('Subscribe URL:', req.body.SubscribeURL);
|
||||
|
||||
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
|
||||
// Legitimate URLs look like:
|
||||
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
|
||||
const subscribeURL: unknown = req.body.SubscribeURL;
|
||||
if (typeof subscribeURL !== 'string') {
|
||||
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(subscribeURL);
|
||||
} catch {
|
||||
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com.
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Automatically confirm the subscription
|
||||
try {
|
||||
const confirmResponse = await fetch(req.body.SubscribeURL);
|
||||
const confirmResponse = await fetch(subscribeURL);
|
||||
if (confirmResponse.ok) {
|
||||
signale.success('SNS subscription confirmed successfully');
|
||||
return res.status(200).json({
|
||||
@@ -46,7 +74,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Failed to confirm subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
} catch (confirmError) {
|
||||
@@ -54,7 +81,6 @@ export class Webhooks {
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Error confirming subscription',
|
||||
subscribeURL: req.body.SubscribeURL,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -67,6 +93,150 @@ export class Webhooks {
|
||||
|
||||
// Parse the nested SES event from the Message field
|
||||
const body = JSON.parse(req.body.Message);
|
||||
|
||||
// Check if this is an inbound email notification (SES Receiving)
|
||||
if (body.notificationType === 'Received') {
|
||||
signale.info('[WEBHOOK] Received inbound email notification from SES');
|
||||
|
||||
try {
|
||||
// Extract recipient addresses from the inbound email
|
||||
const recipients = body.receipt?.recipients || [];
|
||||
|
||||
if (recipients.length === 0) {
|
||||
signale.warn('[WEBHOOK] No recipients found in inbound email');
|
||||
return res.status(200).json({success: true, message: 'No recipients found'});
|
||||
}
|
||||
|
||||
// For each recipient, identify the domain and create events
|
||||
for (const recipient of recipients) {
|
||||
const recipientEmail = recipient as string;
|
||||
const domain = recipientEmail.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find ALL projects that have this domain verified
|
||||
// A domain can be shared across multiple projects if users are members of both
|
||||
const domainRecords = await prisma.domain.findMany({
|
||||
where: {
|
||||
domain,
|
||||
verified: true, // Only process emails for verified domains
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (domainRecords.length === 0) {
|
||||
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
|
||||
);
|
||||
|
||||
// Extract sender information (same for all projects)
|
||||
const senderEmail = body.mail?.source;
|
||||
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
||||
|
||||
// Process inbound email for each project that has this domain verified
|
||||
for (const domainRecord of domainRecords) {
|
||||
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
||||
|
||||
// Check billing limits before processing inbound email
|
||||
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
signale.warn(
|
||||
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
|
||||
);
|
||||
continue; // Skip this project but continue processing for other projects
|
||||
}
|
||||
|
||||
// Find or create a contact for the sender in this project
|
||||
let contact;
|
||||
if (senderEmail) {
|
||||
contact = await ContactService.upsert(
|
||||
domainRecord.projectId,
|
||||
senderEmail,
|
||||
undefined, // No additional data
|
||||
true, // Subscribe by default for inbound email senders
|
||||
);
|
||||
}
|
||||
|
||||
// Create an Email record for tracking (no actual email content since it's inbound)
|
||||
const inboundEmail = await prisma.email.create({
|
||||
data: {
|
||||
projectId: domainRecord.projectId,
|
||||
contactId: contact!.id,
|
||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||
body: '', // Inbound emails don't have body content in our system
|
||||
from: recipientEmail, // The recipient address that received the email
|
||||
sourceType: EmailSourceType.INBOUND,
|
||||
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||
deliveredAt: new Date(body.mail?.timestamp || new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
// Increment usage counter in cache
|
||||
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
// Record Stripe metering if project has customer
|
||||
if (domainRecord.project.customer) {
|
||||
await MeterService.recordEmailSent(
|
||||
domainRecord.project.customer,
|
||||
1, // Inbound emails count as 1 credit
|
||||
`email_${inboundEmail.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare event data with all inbound email details
|
||||
const eventData = {
|
||||
messageId: body.mail?.messageId,
|
||||
from: senderEmail,
|
||||
fromHeader: senderFromHeader,
|
||||
to: recipientEmail,
|
||||
subject: body.mail?.commonHeaders?.subject,
|
||||
timestamp: body.mail?.timestamp,
|
||||
recipients: body.receipt?.recipients,
|
||||
hasContent: !!body.content,
|
||||
// Security verdicts
|
||||
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||
virusVerdict: body.receipt?.virusVerdict?.status,
|
||||
spfVerdict: body.receipt?.spfVerdict?.status,
|
||||
dkimVerdict: body.receipt?.dkimVerdict?.status,
|
||||
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
|
||||
// Processing metadata
|
||||
processingTimeMillis: body.receipt?.processingTimeMillis,
|
||||
};
|
||||
|
||||
// Create the email.received event (this will trigger workflows)
|
||||
await EventService.trackEvent(
|
||||
domainRecord.projectId,
|
||||
'email.received',
|
||||
contact?.id,
|
||||
inboundEmail.id, // Link the event to the inbound email record
|
||||
eventData,
|
||||
);
|
||||
|
||||
signale.success(
|
||||
`[WEBHOOK] Created email.received event for ${senderEmail} → ${recipientEmail} (project: ${domainRecord.project.name})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({success: true, message: 'Inbound email processed'});
|
||||
} catch (inboundError) {
|
||||
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
|
||||
// Return 200 to acknowledge receipt even if processing failed
|
||||
return res.status(200).json({success: true, message: 'Error processing inbound email'});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle outbound email event notifications (existing logic)
|
||||
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
||||
const messageId = body.mail?.messageId;
|
||||
|
||||
@@ -152,29 +322,62 @@ export class Webhooks {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Bounce':
|
||||
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||
updateData.status = EmailStatus.BOUNCED;
|
||||
updateData.bouncedAt = now;
|
||||
// Unsubscribe contact on bounce
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType: body.bounce?.bounceType,
|
||||
bouncedAt: now.toISOString(),
|
||||
};
|
||||
case 'Bounce': {
|
||||
const bounceType = body.bounce?.bounceType;
|
||||
const isPermanentBounce = bounceType === 'Permanent';
|
||||
const isTransientBounce = bounceType === 'Transient';
|
||||
|
||||
// Send notification about bounce
|
||||
await NtfyService.notifyEmailBounce(
|
||||
email.project.name,
|
||||
email.projectId,
|
||||
email.contact.email,
|
||||
body.bounce?.bounceType,
|
||||
);
|
||||
if (isPermanentBounce) {
|
||||
// Hard bounce - counts toward bounce rate and unsubscribes contact
|
||||
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||
updateData.status = EmailStatus.BOUNCED;
|
||||
updateData.bouncedAt = now;
|
||||
// Unsubscribe contact on permanent bounce
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
bouncedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
// Send notification about permanent bounce
|
||||
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||
} else if (isTransientBounce) {
|
||||
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
|
||||
signale.info(
|
||||
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
|
||||
);
|
||||
// Don't update email status or unsubscribe contact
|
||||
// Just track the event for visibility
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
transientBounce: true,
|
||||
};
|
||||
} else {
|
||||
// Unknown bounce type - treat as permanent to be safe
|
||||
signale.warn(
|
||||
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
|
||||
);
|
||||
updateData.status = EmailStatus.BOUNCED;
|
||||
updateData.bouncedAt = now;
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
bouncedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Complaint':
|
||||
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
||||
@@ -208,8 +411,10 @@ export class Webhooks {
|
||||
// Track event (this will trigger workflows)
|
||||
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
||||
|
||||
// Check security limits for bounce and complaint events
|
||||
if (eventType === 'Bounce' || eventType === 'Complaint') {
|
||||
// Check security limits only for permanent bounces and complaints
|
||||
// Transient bounces (soft bounces) don't count toward bounce rate
|
||||
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
|
||||
if (isPermanentBounce || eventType === 'Complaint') {
|
||||
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {WorkflowService} from '../services/WorkflowService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -18,7 +16,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
@@ -38,7 +36,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
|
||||
try {
|
||||
@@ -61,7 +59,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
@@ -81,7 +79,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, eventName, enabled, allowReentry} = req.body;
|
||||
|
||||
if (!name) {
|
||||
@@ -111,7 +109,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
|
||||
|
||||
@@ -139,7 +137,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
@@ -159,7 +157,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {type, name, position, config, templateId, autoConnect} = req.body;
|
||||
|
||||
@@ -191,7 +189,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
const {name, position, config, templateId} = req.body;
|
||||
@@ -218,7 +216,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
|
||||
@@ -239,7 +237,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {fromStepId, toStepId, condition, priority} = req.body;
|
||||
|
||||
@@ -269,7 +267,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const transitionId = req.params.transitionId;
|
||||
|
||||
@@ -290,7 +288,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async startExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {contactId, context} = req.body;
|
||||
|
||||
@@ -315,7 +313,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
@@ -338,7 +336,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
@@ -359,7 +357,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
@@ -380,7 +378,7 @@ export class Workflows {
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
|
||||
@@ -74,9 +74,7 @@ export class NotFound extends HttpException {
|
||||
* @param id Optional resource identifier to include in the message
|
||||
*/
|
||||
public constructor(resource: string, id?: string) {
|
||||
const message = id
|
||||
? `${resource} with ID "${id}" was not found`
|
||||
: `That ${resource.toLowerCase()} was not found`;
|
||||
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
|
||||
|
||||
// Map common resources to specific error codes
|
||||
const errorCodeMap: Record<string, ErrorCode> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock MeterService
|
||||
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Mock the email processor logic
|
||||
// In a real implementation, you would:
|
||||
// 1. Create job tester
|
||||
// 2. Mock SES service
|
||||
// 3. Process the job
|
||||
// 4. Verify status changes
|
||||
|
||||
// Simulate processing
|
||||
await prisma.email.update({
|
||||
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: [
|
||||
attachments: toPrismaJson([
|
||||
{
|
||||
filename: 'document.pdf',
|
||||
content: 'base64encodedcontent',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
|
||||
from: '[email protected]',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: [
|
||||
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
|
||||
] as unknown as Prisma.InputJsonValue,
|
||||
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
|
||||
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
describe('Scheduled Campaign Processor', () => {
|
||||
let projectId: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type {ApiRequestCleanupJobData} from '@plunk/types';
|
||||
import type {Job} from 'bullmq';
|
||||
import {Worker} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
@@ -5,7 +6,6 @@ import signale from 'signale';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
|
||||
|
||||
/**
|
||||
* API Request Cleanup Worker
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* Processes campaign batches (queues emails for each contact in the batch)
|
||||
*/
|
||||
|
||||
import type {CampaignBatchJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
||||
import {campaignQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createCampaignWorker() {
|
||||
const worker = new Worker<CampaignBatchJobData>(
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Processes domain verification jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {DomainVerificationJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
|
||||
import {domainVerificationQueue} from '../services/QueueService.js';
|
||||
|
||||
import {checkDomainVerifications} from './domain-verification.js';
|
||||
|
||||
|
||||
@@ -6,10 +6,14 @@
|
||||
* Scheduled to run every 5 minutes via repeatable jobs
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
|
||||
@@ -26,7 +30,15 @@ export async function checkDomainVerifications() {
|
||||
// Process domains in batches of 99 (AWS SES limit is 100)
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const domains = await prisma.domain.findMany({
|
||||
select: {id: true, domain: true, projectId: true, verified: true},
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
projectId: true,
|
||||
verified: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
|
||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
if (
|
||||
error?.Code === 'Throttling' ||
|
||||
error?.name === 'Throttling' ||
|
||||
error?.message?.includes('Throttling')
|
||||
) {
|
||||
signale.warn(
|
||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
signale.error(
|
||||
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +120,34 @@ export async function checkDomainVerifications() {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
|
||||
}
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||
const ttl = 604800; // 7 days
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
@@ -111,6 +157,38 @@ export async function checkDomainVerifications() {
|
||||
if (dbDomain.verified && !isVerified) {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`);
|
||||
}
|
||||
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
*/
|
||||
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
||||
import {emailQueue} from '../services/QueueService.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Determine the email sending rate limit (emails per second)
|
||||
@@ -116,10 +117,25 @@ export async function createEmailWorker() {
|
||||
const fromName = email.fromName || email.project.name;
|
||||
const fromEmail = email.from;
|
||||
|
||||
// Parse custom headers from JSON
|
||||
const customHeaders =
|
||||
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
|
||||
? (email.headers as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Check for custom recipient override in headers
|
||||
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||
|
||||
// Remove internal headers before sending
|
||||
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||
}
|
||||
|
||||
// Build recipient with name if available
|
||||
const recipient: {name?: string; email: string} | string = email.toName
|
||||
? {name: email.toName, email: email.contact.email}
|
||||
: email.contact.email;
|
||||
? {name: email.toName, email: recipientEmail}
|
||||
: recipientEmail;
|
||||
|
||||
// Determine tracking based on project settings and email type
|
||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||
@@ -136,6 +152,7 @@ export async function createEmailWorker() {
|
||||
html: compiledHtml,
|
||||
},
|
||||
reply: email.replyTo || undefined,
|
||||
headers: publicHeaders,
|
||||
tracking: shouldTrack,
|
||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||
});
|
||||
@@ -203,6 +220,7 @@ export async function createEmailWorker() {
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Processes CSV contact imports with validation and batch processing
|
||||
*/
|
||||
|
||||
import type {ContactImportJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
import signale from 'signale';
|
||||
@@ -10,7 +11,7 @@ import signale from 'signale';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
|
||||
import {importQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
*/
|
||||
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import type {ScheduledCampaignJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
|
||||
import {scheduledQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createScheduledCampaignWorker() {
|
||||
const worker = new Worker<ScheduledCampaignJobData>(
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
* Processes segment count update jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {SegmentCountJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
|
||||
import {segmentCountQueue} from '../services/QueueService.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
|
||||
/**
|
||||
@@ -17,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
|
||||
* - For segments without trackMembership: only update counts
|
||||
*/
|
||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
|
||||
|
||||
// Get all segments for this project, separating tracked vs non-tracked
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
@@ -28,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
||||
);
|
||||
|
||||
// Process tracked segments with full membership computation (creates events)
|
||||
let updatedSegmentCount = 0;
|
||||
let totalAdded = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
if (trackedSegments.length > 0) {
|
||||
for (const segment of trackedSegments) {
|
||||
try {
|
||||
signale.info(
|
||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
||||
);
|
||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||
signale.success(
|
||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
||||
);
|
||||
|
||||
// Notify about segment membership update only if there were actual changes
|
||||
if (projectName && (result.added > 0 || result.removed > 0)) {
|
||||
await NtfyService.notifySegmentMembershipComputed(
|
||||
segment.name,
|
||||
projectName,
|
||||
projectId,
|
||||
result.total,
|
||||
result.added,
|
||||
result.removed,
|
||||
);
|
||||
// Track segments with actual changes for bundled notification
|
||||
if (result.added > 0 || result.removed > 0) {
|
||||
updatedSegmentCount++;
|
||||
totalAdded += result.added;
|
||||
totalRemoved += result.removed;
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||
// Continue with other segments
|
||||
}
|
||||
}
|
||||
|
||||
// Send bundled notification if there were any changes
|
||||
if (projectName && updatedSegmentCount > 0) {
|
||||
await NtfyService.notifySegmentMembershipBundled(
|
||||
projectName,
|
||||
projectId,
|
||||
updatedSegmentCount,
|
||||
totalAdded,
|
||||
totalRemoved,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process non-tracked segments with count-only update (lightweight)
|
||||
if (nonTrackedSegments.length > 0) {
|
||||
try {
|
||||
await SegmentService.refreshAllMemberCounts(projectId);
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||
}
|
||||
@@ -79,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||
const {projectId} = job.data;
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
||||
|
||||
try {
|
||||
if (projectId) {
|
||||
// Process specific project
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
||||
await processProjectSegments(projectId);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
||||
} else {
|
||||
// Process all active projects
|
||||
const projects = await prisma.project.findMany({
|
||||
@@ -94,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||
|
||||
// Process projects in batches to avoid overwhelming the database
|
||||
const PROJECT_BATCH_SIZE = 10;
|
||||
@@ -104,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
await Promise.all(
|
||||
batch.map(async project => {
|
||||
try {
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
||||
await processProjectSegments(project.id, project.name);
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||
// Don't throw - continue with other projects
|
||||
@@ -119,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||
@@ -147,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Processes workflow steps from the queue (for delayed steps)
|
||||
*/
|
||||
|
||||
import type {WorkflowStepJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
||||
import {workflowQueue} from '../services/QueueService.js';
|
||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||
|
||||
export function createWorkflowWorker() {
|
||||
|
||||
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
|
||||
projectId = project.id;
|
||||
userId = user.id;
|
||||
|
||||
// Mock request object
|
||||
req = {
|
||||
method: 'POST',
|
||||
path: '/v1/send',
|
||||
@@ -334,12 +333,7 @@ describe('Request Logger Middleware', () => {
|
||||
await res.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// TODO: Add assertion to verify request was NOT logged when disabled
|
||||
// const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
// where: {id: 'test-request-id-123'},
|
||||
// });
|
||||
// expect(loggedRequest).toBeNull();
|
||||
|
||||
|
||||
// Restore original value
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.REQUEST_LOGGING = originalEnv;
|
||||
|
||||
+28
-109
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import type {AuthResponse} from '@plunk/types';
|
||||
|
||||
export interface AuthResponse {
|
||||
type: 'jwt' | 'apiKey';
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
}
|
||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
|
||||
/**
|
||||
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
||||
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require project access
|
||||
* Validates that the user is authenticated and has access to the project specified in X-Project-Id header
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireProjectAccess = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// First authenticate the user
|
||||
const userId = parseJwt(req);
|
||||
|
||||
// Get project ID from header
|
||||
const projectId = req.headers['x-project-id'] as string | undefined;
|
||||
|
||||
if (!projectId) {
|
||||
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Verify user has access to this project and get project status
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require public API key authentication (for /v1/track endpoint only)
|
||||
* Validates that the request has a valid public key and sets the project
|
||||
@@ -174,12 +106,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by public key only
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
public: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.public(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -193,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by secret key only
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
secret: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -262,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
// Look up project by secret key only (public keys not allowed)
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
secret: apiKey,
|
||||
},
|
||||
});
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
@@ -360,18 +292,8 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
|
||||
// Verify user has access to this project and get project status
|
||||
const [membership, project] = await Promise.all([
|
||||
prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
}),
|
||||
MembershipService.getMembership(userId, projectId),
|
||||
ProjectService.id(projectId),
|
||||
]);
|
||||
|
||||
if (!membership) {
|
||||
@@ -383,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
} as AuthResponse;
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
@@ -414,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
*/
|
||||
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (auth.type === 'apiKey') {
|
||||
return next();
|
||||
@@ -424,10 +346,7 @@ export const requireEmailVerified = async (req: Request, res: Response, next: Ne
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {id: auth.userId},
|
||||
select: {emailVerified: true, type: true},
|
||||
});
|
||||
const user = await UserService.id(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
|
||||
@@ -1,61 +1,12 @@
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Activity types that can be tracked
|
||||
*/
|
||||
export enum ActivityType {
|
||||
EVENT_TRIGGERED = 'event.triggered',
|
||||
EMAIL_SENT = 'email.sent',
|
||||
EMAIL_DELIVERED = 'email.delivered',
|
||||
EMAIL_OPENED = 'email.opened',
|
||||
EMAIL_CLICKED = 'email.clicked',
|
||||
EMAIL_BOUNCED = 'email.bounced',
|
||||
CAMPAIGN_SENT = 'campaign.sent',
|
||||
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
||||
WORKFLOW_STARTED = 'workflow.started',
|
||||
WORKFLOW_COMPLETED = 'workflow.completed',
|
||||
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified activity item
|
||||
*/
|
||||
export interface Activity {
|
||||
id: string;
|
||||
type: ActivityType;
|
||||
timestamp: Date;
|
||||
contactEmail?: string;
|
||||
contactId?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated activity response
|
||||
*/
|
||||
export interface PaginatedActivities {
|
||||
activities: Activity[];
|
||||
nextCursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity stats for dashboard
|
||||
*/
|
||||
export interface ActivityStats {
|
||||
totalEvents: number;
|
||||
totalEmailsSent: number;
|
||||
totalEmailsOpened: number;
|
||||
totalEmailsClicked: number;
|
||||
totalWorkflowsStarted: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity Service
|
||||
*
|
||||
@@ -107,7 +58,7 @@ export class ActivityService {
|
||||
contactId?: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<PaginatedActivities> {
|
||||
): Promise<CursorPaginatedResponse<Activity>> {
|
||||
// Cap limit to prevent abuse
|
||||
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
||||
|
||||
@@ -158,8 +109,8 @@ export class ActivityService {
|
||||
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
|
||||
|
||||
return {
|
||||
activities: results,
|
||||
nextCursor,
|
||||
data: results,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
@@ -415,15 +366,41 @@ export class ActivityService {
|
||||
...(contactId ? {contactId} : {}),
|
||||
};
|
||||
|
||||
// Build OR conditions to filter by the appropriate timestamp field for each activity type
|
||||
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
|
||||
const orConditions: Prisma.EmailWhereInput[] = [];
|
||||
|
||||
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
|
||||
orConditions.push({sentAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_RECEIVED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
|
||||
orConditions.push({openedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
|
||||
orConditions.push({clickedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
|
||||
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
|
||||
orConditions.push({complainedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
|
||||
// If no OR conditions, return empty (shouldn't happen but defensive)
|
||||
if (orConditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emails = await prisma.email.findMany({
|
||||
where: {
|
||||
...where,
|
||||
createdAt: cursorTimestamp
|
||||
? {
|
||||
...dateFilter,
|
||||
lt: cursorTimestamp,
|
||||
}
|
||||
: dateFilter,
|
||||
OR: orConditions,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: limit,
|
||||
@@ -450,6 +427,27 @@ export class ActivityService {
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to check if timestamp is within date range
|
||||
const isInDateRange = (timestamp: Date | null) => {
|
||||
if (!timestamp) return false;
|
||||
|
||||
// Check against date filter
|
||||
const time = timestamp.getTime();
|
||||
if (dateFilter.gte) {
|
||||
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
|
||||
if (time < gteTime) return false;
|
||||
}
|
||||
if (dateFilter.lte) {
|
||||
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
|
||||
if (time > lteTime) return false;
|
||||
}
|
||||
|
||||
// Check against cursor for pagination
|
||||
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Convert each email into multiple activities based on its state
|
||||
for (const email of emails) {
|
||||
const baseMetadata = {
|
||||
@@ -464,7 +462,7 @@ export class ActivityService {
|
||||
workflowName: email.workflowExecution?.workflow?.name,
|
||||
};
|
||||
|
||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
|
||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_sent`,
|
||||
type: ActivityType.EMAIL_SENT,
|
||||
@@ -475,7 +473,11 @@ export class ActivityService {
|
||||
});
|
||||
}
|
||||
|
||||
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
|
||||
if (
|
||||
email.deliveredAt &&
|
||||
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
|
||||
isInDateRange(email.deliveredAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_delivered`,
|
||||
type: ActivityType.EMAIL_DELIVERED,
|
||||
@@ -486,7 +488,23 @@ export class ActivityService {
|
||||
});
|
||||
}
|
||||
|
||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
|
||||
if (
|
||||
email.deliveredAt &&
|
||||
email.sourceType === 'INBOUND' &&
|
||||
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
|
||||
isInDateRange(email.deliveredAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_received`,
|
||||
type: ActivityType.EMAIL_RECEIVED,
|
||||
timestamp: email.deliveredAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: baseMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_opened`,
|
||||
type: ActivityType.EMAIL_OPENED,
|
||||
@@ -501,7 +519,7 @@ export class ActivityService {
|
||||
}
|
||||
|
||||
// Email clicked
|
||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
|
||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_clicked`,
|
||||
type: ActivityType.EMAIL_CLICKED,
|
||||
@@ -516,7 +534,7 @@ export class ActivityService {
|
||||
}
|
||||
|
||||
// Email bounced
|
||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
|
||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_bounced`,
|
||||
type: ActivityType.EMAIL_BOUNCED,
|
||||
@@ -529,6 +547,25 @@ export class ActivityService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Email complaint
|
||||
if (
|
||||
email.complainedAt &&
|
||||
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
|
||||
isInDateRange(email.complainedAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_complaint`,
|
||||
type: ActivityType.EMAIL_COMPLAINT,
|
||||
timestamp: email.complainedAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
error: email.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
|
||||
@@ -58,14 +58,16 @@ export class AnalyticsService {
|
||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.timeseries(
|
||||
projectId,
|
||||
limitedStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// Raw SQL query for efficient daily aggregation
|
||||
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
|
||||
const result = await prisma.$queryRaw<
|
||||
{
|
||||
date: Date;
|
||||
@@ -191,7 +193,11 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.campaignStats(
|
||||
projectId,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
@@ -295,7 +301,12 @@ export class AnalyticsService {
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
||||
const cacheKey = Keys.Analytics.topEvents(
|
||||
projectId,
|
||||
limit,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
|
||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
@@ -8,41 +9,9 @@ import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
* Usage information for a specific email category
|
||||
*/
|
||||
export interface CategoryUsage {
|
||||
limit: number | null; // null = unlimited
|
||||
usage: number;
|
||||
percentage: number; // 0-100
|
||||
isWarning: boolean; // true if >= 80%
|
||||
isBlocked: boolean; // true if >= 100%
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete billing limits and usage for a project
|
||||
*/
|
||||
export interface BillingLimitsResponse {
|
||||
workflows: CategoryUsage;
|
||||
campaigns: CategoryUsage;
|
||||
transactional: CategoryUsage;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of limit check
|
||||
*/
|
||||
export interface LimitCheckResult {
|
||||
allowed: boolean;
|
||||
warning: boolean; // true if >= 80% but < 100%
|
||||
usage: number;
|
||||
limit: number | null;
|
||||
percentage: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing Limit Service
|
||||
* Handles usage tracking and enforcement of billing limits per email category
|
||||
@@ -188,6 +157,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -214,6 +184,9 @@ export class BillingLimitService {
|
||||
case EmailSourceType.TRANSACTIONAL:
|
||||
limit = project.billingLimitTransactional;
|
||||
break;
|
||||
case EmailSourceType.INBOUND:
|
||||
limit = project.billingLimitInbound;
|
||||
break;
|
||||
default:
|
||||
limit = null;
|
||||
}
|
||||
@@ -222,7 +195,8 @@ export class BillingLimitService {
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null;
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||
// Only enforce free tier limits if billing is enabled
|
||||
@@ -399,6 +373,7 @@ export class BillingLimitService {
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -421,10 +396,11 @@ export class BillingLimitService {
|
||||
}
|
||||
|
||||
// Get usage for all categories in parallel
|
||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
||||
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||
]);
|
||||
|
||||
// Helper to calculate category usage
|
||||
@@ -443,18 +419,19 @@ export class BillingLimitService {
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null;
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||
// Only show free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / limit) * 100;
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
const isBlocked = totalUsage >= limit;
|
||||
|
||||
// For free tier, show the same limit and total usage for all three categories
|
||||
// For free tier, show the same limit and total usage for all four categories
|
||||
// This makes it clear in the UI that it's a shared limit
|
||||
const sharedUsageInfo: CategoryUsage = {
|
||||
limit,
|
||||
@@ -468,6 +445,7 @@ export class BillingLimitService {
|
||||
workflows: sharedUsageInfo,
|
||||
campaigns: sharedUsageInfo,
|
||||
transactional: sharedUsageInfo,
|
||||
inbound: sharedUsageInfo,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
@@ -477,6 +455,7 @@ export class BillingLimitService {
|
||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||
currency,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -501,6 +480,7 @@ export class BillingLimitService {
|
||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||
];
|
||||
|
||||
await Promise.all(keys.map(key => redis.del(key)));
|
||||
@@ -524,11 +504,13 @@ export class BillingLimitService {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
newLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
@@ -562,6 +544,14 @@ export class BillingLimitService {
|
||||
);
|
||||
}
|
||||
|
||||
// Check inbound limit
|
||||
if (oldLimits.inbound !== newLimits.inbound) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||
signale.debug(
|
||||
@@ -611,16 +601,20 @@ export class BillingLimitService {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent === '1') {
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -637,11 +631,6 @@ export class BillingLimitService {
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||
|
||||
// Mark that we've sent the warning email (expires at end of month)
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
await redis.setex(cacheKey, ttl, '1');
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||
}
|
||||
@@ -664,16 +653,20 @@ export class BillingLimitService {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||
|
||||
const alreadySent = await redis.get(cacheKey);
|
||||
if (alreadySent === '1') {
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -689,11 +682,6 @@ export class BillingLimitService {
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||
|
||||
// Mark that we've sent the limit email (expires at end of month)
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
await redis.setex(cacheKey, ttl, '1');
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||
import type {FilterCondition} from '@plunk/types';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -18,32 +19,6 @@ import {sendRawEmail} from './SESService.js';
|
||||
|
||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||
|
||||
export interface CreateCampaignData {
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCampaignData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
audienceType?: CampaignAudienceType;
|
||||
audienceCondition?: FilterCondition;
|
||||
segmentId?: string;
|
||||
}
|
||||
|
||||
export class CampaignService {
|
||||
/**
|
||||
* Create a new campaign
|
||||
@@ -85,7 +60,7 @@ export class CampaignService {
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
audienceType: data.audienceType,
|
||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||
segmentId: data.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
@@ -133,7 +108,7 @@ export class CampaignService {
|
||||
if (data.audienceCondition) {
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
|
||||
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
|
||||
}
|
||||
|
||||
if (data.segmentId !== undefined) {
|
||||
@@ -209,7 +184,7 @@ export class CampaignService {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
|
||||
): Promise<PaginatedResponse<Campaign>> {
|
||||
const {status, page = 1, pageSize = 20} = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
@@ -232,7 +207,7 @@ export class CampaignService {
|
||||
]);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
data: campaigns,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -476,6 +451,7 @@ export class CampaignService {
|
||||
const contactData =
|
||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
@@ -589,6 +565,7 @@ export class CampaignService {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
@@ -747,7 +724,7 @@ export class CampaignService {
|
||||
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
||||
|
||||
case CampaignAudienceType.FILTERED: {
|
||||
const condition = campaign.audienceCondition as unknown as FilterCondition;
|
||||
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
|
||||
if (!condition) {
|
||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||
}
|
||||
@@ -783,7 +760,19 @@ export class CampaignService {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
if (segment.type === 'STATIC') {
|
||||
return {
|
||||
...baseWhere,
|
||||
segmentMemberships: {
|
||||
some: {
|
||||
segmentId,
|
||||
exitedAt: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
import {isValidLanguageCode} from '@plunk/shared';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {EventService} from './EventService.js';
|
||||
|
||||
export interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export class ContactService {
|
||||
/**
|
||||
* Get all contacts for a project with cursor-based pagination
|
||||
@@ -23,7 +16,7 @@ export class ContactService {
|
||||
limit = 20,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
): Promise<PaginatedContacts> {
|
||||
): Promise<CursorPaginatedResponse<Contact>> {
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
...(search
|
||||
@@ -58,7 +51,7 @@ export class ContactService {
|
||||
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
||||
|
||||
return {
|
||||
contacts: results,
|
||||
data: results,
|
||||
total,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
@@ -205,6 +198,7 @@ export class ContactService {
|
||||
email: string,
|
||||
data?: Record<string, unknown>,
|
||||
subscribed?: boolean,
|
||||
defaultSubscribed: boolean = true,
|
||||
): Promise<Contact> {
|
||||
// Find existing contact
|
||||
const existing = await prisma.contact.findFirst({
|
||||
@@ -227,21 +221,23 @@ export class ContactService {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (typeof value === 'string') {
|
||||
if (!isValidLanguageCode(value)) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
|
||||
);
|
||||
}
|
||||
} else if (value !== null && value !== undefined) {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
@@ -272,7 +268,7 @@ export class ContactService {
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: existing.id},
|
||||
data: {
|
||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
...(subscribed !== undefined ? {subscribed} : {}),
|
||||
},
|
||||
});
|
||||
@@ -292,8 +288,8 @@ export class ContactService {
|
||||
data: {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? true,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -678,59 +674,11 @@ export class ContactService {
|
||||
return {deletedFrom: result};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
*/
|
||||
public static async bulkSubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number}> {
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
@@ -779,10 +727,7 @@ export class ContactService {
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
*/
|
||||
public static async bulkUnsubscribe(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{updated: number}> {
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
@@ -829,10 +774,7 @@ export class ContactService {
|
||||
/**
|
||||
* Bulk delete contacts
|
||||
*/
|
||||
public static async bulkDelete(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
): Promise<{deleted: number}> {
|
||||
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
|
||||
const result = await prisma.contact.deleteMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
@@ -843,6 +785,51 @@ export class ContactService {
|
||||
return {deleted: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track events sequentially to avoid database deadlocks
|
||||
* Processes events one at a time with error handling
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||
import {
|
||||
deleteIdentity,
|
||||
disableFeedbackForwarding,
|
||||
getDomainVerificationAttributes,
|
||||
verifyDomain,
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
@@ -67,6 +77,43 @@ export class DomainService {
|
||||
|
||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (attributes.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(domain.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
signale.warn(
|
||||
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain if verification status changed
|
||||
if (attributes.status === 'Success' && !domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
@@ -79,8 +126,42 @@ export class DomainService {
|
||||
},
|
||||
});
|
||||
|
||||
// Disable feedback forwarding for verified domain
|
||||
try {
|
||||
await disableFeedbackForwarding(domain.domain);
|
||||
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
|
||||
}
|
||||
|
||||
// Send notification about domain verified
|
||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||
const ttl = 604800; // 7 days
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError);
|
||||
}
|
||||
} else if (attributes.status !== 'Success' && domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: {id: domainId},
|
||||
@@ -93,7 +174,41 @@ export class DomainService {
|
||||
});
|
||||
|
||||
// Send notification about domain verification failed
|
||||
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
await NtfyService.notifyDomainVerificationFailed(
|
||||
domain.domain,
|
||||
updatedDomain.project.name,
|
||||
updatedDomain.project.id,
|
||||
);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -184,6 +299,28 @@ export class DomainService {
|
||||
|
||||
await prisma.domain.delete({where: {id: domainId}});
|
||||
|
||||
// Check if this domain is still attached to another project
|
||||
const domainExistsElsewhere = await prisma.domain.findFirst({
|
||||
where: {
|
||||
domain: domainName,
|
||||
},
|
||||
});
|
||||
|
||||
// If domain is not used by any other project, remove it from AWS SES
|
||||
if (!domainExistsElsewhere) {
|
||||
try {
|
||||
await deleteIdentity(domainName);
|
||||
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
|
||||
} catch (error) {
|
||||
// Log error but don't fail the domain removal if AWS cleanup fails
|
||||
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
|
||||
}
|
||||
} else {
|
||||
signale.info(
|
||||
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Send notification about domain removal
|
||||
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
|
||||
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
@@ -17,6 +18,8 @@ interface Attachment {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}
|
||||
|
||||
interface SendEmailParams {
|
||||
@@ -34,6 +37,7 @@ interface SendEmailParams {
|
||||
campaignId?: string;
|
||||
workflowExecutionId?: string;
|
||||
workflowStepExecutionId?: string;
|
||||
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,8 +95,8 @@ export class EmailService {
|
||||
fromName: params.fromName,
|
||||
toName: params.toName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
templateId: params.templateId,
|
||||
status: EmailStatus.PENDING,
|
||||
@@ -152,8 +156,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
campaignId: params.campaignId,
|
||||
@@ -192,7 +196,8 @@ export class EmailService {
|
||||
|
||||
// Check subscription status for marketing emails
|
||||
// Transactional emails should always be sent regardless of subscription status
|
||||
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
|
||||
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
|
||||
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: {id: params.contactId},
|
||||
select: {subscribed: true},
|
||||
@@ -213,8 +218,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
workflowExecutionId: params.workflowExecutionId,
|
||||
@@ -241,6 +246,12 @@ export class EmailService {
|
||||
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
||||
}
|
||||
|
||||
// If custom recipient email is provided, store it in headers for later use
|
||||
const emailHeaders = params.headers ? {...params.headers} : {};
|
||||
if (params.recipientEmail) {
|
||||
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
|
||||
}
|
||||
|
||||
const email = await prisma.email.create({
|
||||
data: {
|
||||
projectId: params.projectId,
|
||||
@@ -250,8 +261,8 @@ export class EmailService {
|
||||
from: params.from,
|
||||
fromName: params.fromName,
|
||||
replyTo: params.replyTo,
|
||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
||||
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
|
||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||
sourceType,
|
||||
templateId: params.templateId,
|
||||
workflowExecutionId: params.workflowExecutionId,
|
||||
@@ -332,6 +343,7 @@ export class EmailService {
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
data: {
|
||||
id: email.contact.id,
|
||||
email: email.contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
@@ -359,10 +371,25 @@ export class EmailService {
|
||||
? (email.headers as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Check for custom recipient override in headers
|
||||
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||
|
||||
// Remove internal headers before sending
|
||||
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||
}
|
||||
|
||||
// Parse attachments from JSON
|
||||
const attachments =
|
||||
email.attachments && Array.isArray(email.attachments)
|
||||
? (email.attachments as Array<{filename: string; content: string; contentType: string}>)
|
||||
? (email.attachments as Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}>)
|
||||
: undefined;
|
||||
|
||||
// Determine tracking based on project settings and email type
|
||||
@@ -374,13 +401,13 @@ export class EmailService {
|
||||
name: fromName,
|
||||
email: fromEmail,
|
||||
},
|
||||
to: [email.contact.email],
|
||||
to: [recipientEmail],
|
||||
content: {
|
||||
subject: formattedEmail.subject,
|
||||
html: compiledHtml,
|
||||
},
|
||||
reply: email.replyTo || undefined,
|
||||
headers: customHeaders,
|
||||
headers: publicHeaders,
|
||||
attachments: attachments,
|
||||
tracking: shouldTrack,
|
||||
});
|
||||
@@ -502,6 +529,42 @@ export class EmailService {
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Update campaign stats if applicable
|
||||
if (email.campaignId) {
|
||||
const campaignUpdate: Prisma.CampaignUpdateInput = {};
|
||||
|
||||
switch (eventType) {
|
||||
case 'delivered':
|
||||
campaignUpdate.deliveredCount = {increment: 1};
|
||||
break;
|
||||
|
||||
case 'opened':
|
||||
// Only increment unique opens to match getStats logic
|
||||
if (!email.openedAt) {
|
||||
campaignUpdate.openedCount = {increment: 1};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'clicked':
|
||||
// Only increment unique clicks to match getStats logic
|
||||
if (!email.clickedAt) {
|
||||
campaignUpdate.clickedCount = {increment: 1};
|
||||
}
|
||||
break;
|
||||
|
||||
case 'bounced':
|
||||
campaignUpdate.bouncedCount = {increment: 1};
|
||||
break;
|
||||
}
|
||||
|
||||
if (Object.keys(campaignUpdate).length > 0) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: campaignUpdate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Track event
|
||||
await prisma.event.create({
|
||||
data: {
|
||||
@@ -509,7 +572,7 @@ export class EmailService {
|
||||
contactId: email.contactId,
|
||||
emailId: email.id,
|
||||
name: `email.${eventType}`,
|
||||
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
|
||||
data: metadata ? toPrismaJson(metadata) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -530,10 +593,11 @@ export class EmailService {
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
|
||||
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
||||
prisma.email.count({where}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
||||
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
||||
@@ -544,6 +608,7 @@ export class EmailService {
|
||||
total,
|
||||
sent,
|
||||
delivered,
|
||||
received,
|
||||
opened,
|
||||
clicked,
|
||||
bounced,
|
||||
|
||||
@@ -1,26 +1,154 @@
|
||||
import {promises as dns} from 'dns';
|
||||
import {run} from '@zootools/email-spell-checker';
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
import {redis} from '../database/redis.js';
|
||||
|
||||
export interface EmailVerificationResult {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isTypo: boolean;
|
||||
isPlusAddressed: boolean;
|
||||
domainExists: boolean;
|
||||
hasMxRecords: boolean;
|
||||
suggestedEmail?: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
const DISPOSABLE_DOMAINS_URL =
|
||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||
const PERSONAL_DOMAINS_URL =
|
||||
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
|
||||
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||
|
||||
// Known email forwarding/alias services
|
||||
const FORWARDING_DOMAINS = new Set([
|
||||
'privaterelay.appleid.com', // Apple Sign In
|
||||
'mozmail.com', // Firefox Relay
|
||||
'simplelogin.com', // SimpleLogin
|
||||
'simplelogin.fr',
|
||||
'simplelogin.co',
|
||||
'simplelogin.io',
|
||||
'aleeas.com',
|
||||
'slmail.me',
|
||||
'dralias.com',
|
||||
'8shield.net',
|
||||
'anonaddy.com', // Addy.io
|
||||
'anonaddy.me',
|
||||
'addy.io',
|
||||
'duck.com', // DuckDuckGo
|
||||
'33mail.com', // 33mail
|
||||
'33m.co',
|
||||
'passmail.com', // Proton Pass
|
||||
'passmail.net',
|
||||
'passinbox.com',
|
||||
'passfwd.com',
|
||||
'y.yo.fr',
|
||||
'opayq.com', // IronVest (formerly Blur)
|
||||
'cloak.id', // Cloaked
|
||||
'erine.email', // Erine
|
||||
'use.startmail.com', // StartMail
|
||||
]);
|
||||
|
||||
export class EmailVerificationService {
|
||||
private static disposableDomainsSet: Set<string> | null = null;
|
||||
private static personalDomainsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks for NS records (proves domain exists in DNS)
|
||||
* - Checks for MX records (required for receiving email)
|
||||
* - Checks for A/AAAA records (informational - indicates if domain has a website)
|
||||
* - Detects disposable email addresses
|
||||
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
|
||||
* - Detects forwarding/alias email addresses
|
||||
* - Suggests corrections for common typos
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isAlias: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
isPersonalEmail: false,
|
||||
domainExists: false,
|
||||
hasWebsite: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check if email is from a personal/free email provider
|
||||
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
|
||||
|
||||
// Check if email is from a known forwarding/alias service
|
||||
result.isAlias = this.isForwardingDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Step 1: Check NS records - proves the domain exists in DNS
|
||||
try {
|
||||
const nsRecords = await dns.resolveNs(domain);
|
||||
result.domainExists = nsRecords && nsRecords.length > 0;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist (no nameservers found)');
|
||||
// If domain doesn't exist, no point checking MX/A records
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 2: Check MX records - required for receiving email
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
|
||||
// Step 3: Check if domain has A/AAAA records - informational only
|
||||
// This indicates if the domain has a website/web server
|
||||
// Doesn't affect email validity since email only requires MX records
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Domain doesn't have A/AAAA records (no website), but this is OK for email
|
||||
result.hasWebsite = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the disposable domains list from GitHub
|
||||
@@ -76,85 +204,62 @@ export class EmailVerificationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks if domain exists (DNS A/AAAA records)
|
||||
* - Checks for MX records
|
||||
* - Detects disposable email addresses
|
||||
* - Suggests corrections for common typos
|
||||
* Check if a domain is a known forwarding/alias service
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
domainExists: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
private static isForwardingDomain(domain: string): boolean {
|
||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
/**
|
||||
* Fetch and cache the personal email domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
* Falls back to in-memory cache if Redis fails
|
||||
*/
|
||||
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||
// Return in-memory cache if available
|
||||
if (this.personalDomainsSet) {
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Check if domain exists (has any DNS records)
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.domainExists = true;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist');
|
||||
// Try to get from Redis cache first
|
||||
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const domains = JSON.parse(cached) as string[];
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
}
|
||||
|
||||
// Check MX records (only if domain exists)
|
||||
if (result.domainExists) {
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('No MX records found for domain');
|
||||
// Fetch from GitHub if not in cache
|
||||
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
const text = await response.text();
|
||||
const domains = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||
|
||||
return result;
|
||||
// Cache in Redis
|
||||
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||
|
||||
// Cache in memory
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
} catch (error) {
|
||||
console.error('Error fetching personal email domains:', error);
|
||||
// Return empty set as fallback - don't block email verification
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a personal/free email provider
|
||||
*/
|
||||
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
|
||||
const personalDomains = await this.getPersonalEmailDomains();
|
||||
return personalDomains.has(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {Event} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -32,7 +33,7 @@ export class EventService {
|
||||
contactId,
|
||||
emailId,
|
||||
name: eventName,
|
||||
data: data ? (data as Prisma.InputJsonValue) : undefined,
|
||||
data: data ? toPrismaJson(data) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -472,7 +473,7 @@ export class EventService {
|
||||
contactId,
|
||||
status: 'RUNNING',
|
||||
currentStepId: triggerStep.id,
|
||||
context: context ? (context as Prisma.InputJsonValue) : undefined,
|
||||
context: context ? toPrismaJson(context) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,6 @@
|
||||
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
/**
|
||||
* Priority levels for ntfy notifications
|
||||
* Based on ntfy.sh documentation
|
||||
*/
|
||||
export enum NtfyPriority {
|
||||
MIN = 1, // No vibration/sound, relegated to "Other notifications"
|
||||
LOW = 2, // No vibration/sound, hidden until drawer opened
|
||||
DEFAULT = 3, // Short vibration and sound (standard)
|
||||
HIGH = 4, // Long vibration, pop-over notification
|
||||
MAX = 5, // Long vibration bursts, pop-over notification
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags for ntfy notifications (emoji shortcuts)
|
||||
*/
|
||||
export enum NtfyTag {
|
||||
WARNING = 'warning',
|
||||
ERROR = 'rotating_light',
|
||||
SUCCESS = 'white_check_mark',
|
||||
MONEY = 'money_with_wings',
|
||||
SHIELD = 'shield',
|
||||
ROCKET = 'rocket',
|
||||
BELL = 'bell',
|
||||
CHART = 'chart_with_upwards_trend',
|
||||
SKULL = 'skull',
|
||||
INFO = 'information_source',
|
||||
}
|
||||
|
||||
export interface NtfyNotification {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: NtfyPriority;
|
||||
tags?: NtfyTag[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for sending notifications via ntfy.sh
|
||||
* Supports configurable ntfy server URL via NTFY_URL environment variable
|
||||
@@ -241,6 +207,18 @@ export class NtfyService {
|
||||
* Notify about security warning (non-critical)
|
||||
*/
|
||||
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:security:warning:${projectId}`;
|
||||
const ttl = 3600; // 1 hour
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningText = warnings.join(', ');
|
||||
await this.sendDefault(
|
||||
'Security Warning',
|
||||
@@ -251,7 +229,7 @@ export class NtfyService {
|
||||
|
||||
/**
|
||||
* Notify about email bounce - LOW priority (high volume)
|
||||
* Rate-limited to only send notification every 20 bounces with per-project breakdown
|
||||
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||
*/
|
||||
public static async notifyEmailBounce(
|
||||
projectName: string,
|
||||
@@ -262,63 +240,51 @@ export class NtfyService {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
// Use Redis counters to track bounce count globally and per-project
|
||||
// Use Redis counters to track bounce count globally
|
||||
const globalCountKey = `ntfy:bounce:count`;
|
||||
const projectCountKey = `ntfy:bounce:count:${projectId}`;
|
||||
const projectListKey = `ntfy:bounce:projects`;
|
||||
const bounceListKey = `ntfy:bounce:latest`;
|
||||
|
||||
// Increment global counter
|
||||
const globalCount = await redis.incr(globalCountKey);
|
||||
|
||||
// Increment project-specific counter
|
||||
await redis.incr(projectCountKey);
|
||||
|
||||
// Add project to the set of projects with bounces (for tracking)
|
||||
await redis.sadd(projectListKey, projectId);
|
||||
// Store this bounce event in a list (keep latest 20)
|
||||
const bounceEvent = JSON.stringify({
|
||||
projectName,
|
||||
projectId,
|
||||
recipientEmail,
|
||||
bounceType: bounceType || 'Unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await redis.lpush(bounceListKey, bounceEvent);
|
||||
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
|
||||
|
||||
// Set expiry on first increment (24 hour rolling window)
|
||||
if (globalCount === 1) {
|
||||
await redis.expire(globalCountKey, 86400);
|
||||
await redis.expire(projectListKey, 86400);
|
||||
await redis.expire(bounceListKey, 86400);
|
||||
}
|
||||
// Always refresh project counter expiry to match global window
|
||||
await redis.expire(projectCountKey, 86400);
|
||||
|
||||
// Only send notification every 20 bounces
|
||||
if (globalCount % 20 === 0) {
|
||||
// Get all projects with bounces
|
||||
const projectIds = await redis.smembers(projectListKey);
|
||||
// Get the latest 20 bounces
|
||||
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||
|
||||
// Get bounce counts for each project
|
||||
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
|
||||
for (const pid of projectIds) {
|
||||
const count = await redis.get(`ntfy:bounce:count:${pid}`);
|
||||
if (count) {
|
||||
// Fetch project name from database
|
||||
const {prisma} = await import('../database/prisma.js');
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: pid},
|
||||
select: {name: true},
|
||||
});
|
||||
// Parse and format the bounce events
|
||||
const bounceDetails = latestBounces
|
||||
.map(bounce => {
|
||||
try {
|
||||
const parsed = JSON.parse(bounce);
|
||||
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
projectCounts.push({
|
||||
projectId: pid,
|
||||
count: parseInt(count, 10),
|
||||
name: project?.name || 'Unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
projectCounts.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Build breakdown message
|
||||
const breakdown = projectCounts.map(p => ` • ${p.name} (${p.projectId}): ${p.count}`).join('\n');
|
||||
|
||||
const bounceInfo = bounceType ? ` (${bounceType})` : '';
|
||||
await this.send({
|
||||
title: 'Email Bounces',
|
||||
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
|
||||
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING],
|
||||
});
|
||||
@@ -352,6 +318,19 @@ export class NtfyService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about failed signup attempt with invalid email - LOW priority
|
||||
*/
|
||||
public static async notifyFailedSignupAttempt(email: string, reasons: string[]): Promise<void> {
|
||||
const reasonText = reasons.join(', ');
|
||||
await this.send({
|
||||
title: 'Failed Signup - Invalid Email',
|
||||
message: `Signup attempt blocked for email: ${email}\nReasons: ${reasonText}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about new user account created via OAuth - LOW priority
|
||||
*/
|
||||
@@ -610,6 +589,18 @@ export class NtfyService {
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
||||
const ttl = 86400; // 24 hours
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendDefault(
|
||||
'Billing Limit Warning',
|
||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||
@@ -629,6 +620,18 @@ export class NtfyService {
|
||||
limit: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
||||
const ttl = 86400; // 24 hours
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendUrgent(
|
||||
'Billing Limit Exceeded',
|
||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||
@@ -747,6 +750,32 @@ export class NtfyService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about bundled segment membership updates - LOW priority
|
||||
* Used when multiple segments are updated in a single processing cycle
|
||||
*/
|
||||
public static async notifySegmentMembershipBundled(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
segmentCount: number,
|
||||
totalAdded: number,
|
||||
totalRemoved: number,
|
||||
): Promise<void> {
|
||||
const changes: string[] = [];
|
||||
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
|
||||
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
|
||||
|
||||
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
|
||||
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
|
||||
|
||||
await this.send({
|
||||
title: 'Segment Memberships Updated',
|
||||
message,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.CHART],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about segment deleted - MIN priority
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,21 @@
|
||||
import {type Job, Queue} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
import type {
|
||||
ApiRequestCleanupJobData,
|
||||
BulkContactActionJobData,
|
||||
CampaignBatchJobData,
|
||||
ContactImportJobData,
|
||||
DomainVerificationJobData,
|
||||
ScheduledCampaignJobData,
|
||||
SegmentCountJobData,
|
||||
SendEmailJobData,
|
||||
WorkflowStepJobData,
|
||||
} from '@plunk/types';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
/**
|
||||
* Queue Job Data Types
|
||||
*/
|
||||
|
||||
export interface SendEmailJobData {
|
||||
emailId: string;
|
||||
}
|
||||
|
||||
export interface CampaignBatchJobData {
|
||||
campaignId: string;
|
||||
batchNumber: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
cursor?: string; // For cursor-based pagination
|
||||
}
|
||||
|
||||
export interface WorkflowStepJobData {
|
||||
executionId: string;
|
||||
stepId: string;
|
||||
type?: 'process-step' | 'timeout'; // Job type for different handling
|
||||
stepExecutionId?: string; // For timeout jobs, reference to the step execution
|
||||
}
|
||||
|
||||
export interface ScheduledCampaignJobData {
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface ContactImportJobData {
|
||||
projectId: string;
|
||||
csvData: string; // Base64 encoded CSV content
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface SegmentCountJobData {
|
||||
projectId?: string; // Optional: if provided, only update this project's segments
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface DomainVerificationJobData {
|
||||
// Empty for now - processes all domains
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface ApiRequestCleanupJobData {
|
||||
// Empty - cleans up old API request logs
|
||||
}
|
||||
|
||||
export interface BulkContactActionJobData {
|
||||
projectId: string;
|
||||
contactIds: string[];
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue Configuration
|
||||
*/
|
||||
@@ -324,14 +282,22 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* Get import job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getImportJobStatus(jobId: string) {
|
||||
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||
const job = await importQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
@@ -366,14 +332,22 @@ export class QueueService {
|
||||
|
||||
/**
|
||||
* Get bulk action job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getBulkActionJobStatus(jobId: string) {
|
||||
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||
const job = await bulkContactQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
CreateBucketCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import crypto from 'crypto';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
S3_ENDPOINT,
|
||||
S3_ACCESS_KEY_ID,
|
||||
S3_ACCESS_KEY_SECRET,
|
||||
S3_BUCKET,
|
||||
S3_PUBLIC_URL,
|
||||
S3_FORCE_PATH_STYLE,
|
||||
S3_ENABLED,
|
||||
S3_ENDPOINT,
|
||||
S3_FORCE_PATH_STYLE,
|
||||
S3_PUBLIC_URL,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
|
||||
let bucketExists = true;
|
||||
|
||||
try {
|
||||
// Check if bucket exists
|
||||
await s3Client.send(
|
||||
new HeadBucketCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DASHBOARD_URI,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
@@ -40,6 +40,8 @@ interface SendRawEmailParams {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}[]
|
||||
| null;
|
||||
tracking?: boolean;
|
||||
@@ -101,8 +103,13 @@ export async function sendRawEmail({
|
||||
}
|
||||
|
||||
// Generate unique boundaries for multipart messages
|
||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
||||
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
|
||||
// Format To header with names if provided
|
||||
const toHeader = to
|
||||
@@ -118,17 +125,21 @@ export async function sendRawEmail({
|
||||
// Extract just email addresses for Destinations (SES requirement)
|
||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||
|
||||
// Determine root content type
|
||||
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
if (mixedBoundary) {
|
||||
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||
} else if (relatedBoundary) {
|
||||
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||
}
|
||||
|
||||
// Build raw MIME message
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
let rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${toHeader}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
${
|
||||
mixedBoundary
|
||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
}
|
||||
Content-Type: ${rootContentType}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
@@ -138,29 +149,61 @@ ${
|
||||
}
|
||||
${unsubscribeHeader}
|
||||
|
||||
${mixedBoundary ? `--${mixedBoundary}\n` : ''}${
|
||||
mixedBoundary ? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n` : ''
|
||||
}--${boundary}
|
||||
`;
|
||||
|
||||
// building the body
|
||||
if (mixedBoundary) {
|
||||
rawMessage += `--${mixedBoundary}\n`;
|
||||
if (relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
} else if (relatedBoundary) {
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
|
||||
// If we are nested, we need to specify that this next part is the alternative container
|
||||
if (mixedBoundary || relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||
}
|
||||
|
||||
// The alternative part content (always contains HTML)
|
||||
rawMessage += `--${altBoundary}
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${breakLongLines(content.html, 500)}
|
||||
--${boundary}--
|
||||
${
|
||||
attachments && attachments.length > 0
|
||||
? '\n' +
|
||||
attachments
|
||||
.map(
|
||||
attachment => `--${mixedBoundary}
|
||||
--${altBoundary}--
|
||||
`;
|
||||
|
||||
// Add inline attachments to the related container
|
||||
if (relatedBoundary) {
|
||||
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||
for (const attachment of inlineAttachments) {
|
||||
rawMessage += `\n--${relatedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||
Content-Disposition: inline; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${relatedBoundary}--`;
|
||||
}
|
||||
|
||||
// Add regular attachments to the mixed container
|
||||
if (mixedBoundary) {
|
||||
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||
for (const attachment of regularAttachments) {
|
||||
rawMessage += `\n--${mixedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`,
|
||||
)
|
||||
.join('\n')
|
||||
: ''
|
||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ''}`;
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${mixedBoundary}--`;
|
||||
}
|
||||
|
||||
// Determine which configuration set to use
|
||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||
@@ -258,6 +301,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a verified domain identity from AWS SES
|
||||
*/
|
||||
export const deleteIdentity = async (domain: string): Promise<void> => {
|
||||
await ses.deleteIdentity({Identity: domain});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get AWS SES account sending quota and rate limit
|
||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||
|
||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
@@ -18,16 +19,23 @@ const SECURITY_THRESHOLDS = {
|
||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||
|
||||
// Bounce rate thresholds (hard bounces only)
|
||||
BOUNCE_7DAY_WARNING: 3,
|
||||
BOUNCE_7DAY_CRITICAL: 8,
|
||||
BOUNCE_ALLTIME_WARNING: 2,
|
||||
BOUNCE_ALLTIME_CRITICAL: 5,
|
||||
BOUNCE_7DAY_WARNING: 5,
|
||||
BOUNCE_7DAY_CRITICAL: 10,
|
||||
BOUNCE_ALLTIME_WARNING: 4,
|
||||
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||
|
||||
// Complaint rate thresholds (spam reports)
|
||||
COMPLAINT_7DAY_WARNING: 0.05,
|
||||
COMPLAINT_7DAY_CRITICAL: 0.1,
|
||||
COMPLAINT_ALLTIME_WARNING: 0.02,
|
||||
COMPLAINT_ALLTIME_CRITICAL: 0.08,
|
||||
COMPLAINT_7DAY_WARNING: 0.075,
|
||||
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||
|
||||
// Minimum absolute counts (prevents small sample size false positives)
|
||||
// Both percentage AND absolute count must be exceeded to trigger
|
||||
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||
MIN_BOUNCES_FOR_WARNING: 5,
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
} as const;
|
||||
|
||||
interface RateData {
|
||||
@@ -177,26 +185,7 @@ export class SecurityService {
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
const disabledMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
project: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hasDisabledProject: disabledMemberships.length > 0,
|
||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
||||
};
|
||||
return MembershipService.userHasDisabledProject(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,50 +282,82 @@ export class SecurityService {
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Only enforce if minimum emails threshold is met
|
||||
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
|
||||
if (hasMinimumVolume) {
|
||||
// Check 7-day bounce rate
|
||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
|
||||
} else if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all-time rates (only if all-time volume is sufficient)
|
||||
if (hasMinimumVolumeAllTime) {
|
||||
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check all-time bounce rate
|
||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
||||
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
|
||||
} else if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate
|
||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
||||
);
|
||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check all-time complaint rate
|
||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
||||
violations.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
|
||||
);
|
||||
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
|
||||
warnings.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -408,11 +429,8 @@ export class SecurityService {
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await prisma.membership.findMany({
|
||||
where: {projectId},
|
||||
include: {user: {select: {email: true}}},
|
||||
});
|
||||
const emails = members.map(m => m.user.email);
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter, SegmentType} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -8,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
|
||||
import {EventService} from './EventService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
// Re-export types for use in other services
|
||||
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||
|
||||
export interface PaginatedContacts {
|
||||
contacts: Contact[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert segment name to a URL-safe slug for event names
|
||||
* Example: "VIP Customers" -> "vip-customers"
|
||||
@@ -72,13 +64,35 @@ export class SegmentService {
|
||||
segmentId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
): Promise<PaginatedContacts> {
|
||||
): Promise<PaginatedResponse<Contact>> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, query via SegmentMembership records
|
||||
const [memberships, total] = await Promise.all([
|
||||
prisma.segmentMembership.findMany({
|
||||
where: {segmentId, exitedAt: null},
|
||||
include: {contact: true},
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {enteredAt: 'desc'},
|
||||
}),
|
||||
prisma.segmentMembership.count({where: {segmentId, exitedAt: null}}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: memberships.map(m => m.contact),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
where,
|
||||
@@ -90,7 +104,7 @@ export class SegmentService {
|
||||
]);
|
||||
|
||||
return {
|
||||
contacts,
|
||||
data: contacts,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -106,23 +120,35 @@ export class SegmentService {
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
condition: FilterCondition;
|
||||
type?: SegmentType;
|
||||
condition?: FilterCondition;
|
||||
trackMembership?: boolean;
|
||||
},
|
||||
): Promise<Segment> {
|
||||
// Validate condition
|
||||
this.validateCondition(data.condition);
|
||||
const segmentType = data.type ?? 'DYNAMIC';
|
||||
let memberCount = 0;
|
||||
let conditionJson: Prisma.InputJsonValue | typeof Prisma.JsonNull = Prisma.JsonNull;
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
if (segmentType === 'DYNAMIC') {
|
||||
if (!data.condition) {
|
||||
throw new HttpException(400, 'Condition is required for DYNAMIC segments');
|
||||
}
|
||||
// Validate condition
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
conditionJson = toPrismaJson(data.condition);
|
||||
}
|
||||
|
||||
const segment = await prisma.segment.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
condition: data.condition as unknown as Prisma.InputJsonValue,
|
||||
type: segmentType,
|
||||
condition: conditionJson,
|
||||
trackMembership: data.trackMembership ?? false,
|
||||
memberCount,
|
||||
},
|
||||
@@ -153,12 +179,7 @@ export class SegmentService {
|
||||
},
|
||||
): Promise<Segment> {
|
||||
// First verify segment exists and belongs to project
|
||||
await this.get(projectId, segmentId);
|
||||
|
||||
// Validate condition if provided
|
||||
if (data.condition) {
|
||||
this.validateCondition(data.condition);
|
||||
}
|
||||
const existing = await this.get(projectId, segmentId);
|
||||
|
||||
const updateData: Prisma.SegmentUpdateInput = {};
|
||||
|
||||
@@ -168,8 +189,10 @@ export class SegmentService {
|
||||
if (data.description !== undefined) {
|
||||
updateData.description = data.description;
|
||||
}
|
||||
if (data.condition !== undefined) {
|
||||
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
|
||||
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||
// Validate condition if provided (only for DYNAMIC segments)
|
||||
this.validateCondition(data.condition);
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
@@ -237,10 +260,16 @@ export class SegmentService {
|
||||
*/
|
||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segmentId},
|
||||
@@ -257,7 +286,7 @@ export class SegmentService {
|
||||
public static async refreshAllMemberCounts(projectId: string): Promise<void> {
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, condition: true},
|
||||
select: {id: true, type: true, condition: true},
|
||||
});
|
||||
|
||||
// Process in batches to avoid overwhelming the database
|
||||
@@ -268,9 +297,17 @@ export class SegmentService {
|
||||
await Promise.all(
|
||||
batch.map(async segment => {
|
||||
try {
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const memberCount = await prisma.contact.count({where});
|
||||
let memberCount: number;
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
memberCount = await prisma.segmentMembership.count({
|
||||
where: {segmentId: segment.id, exitedAt: null},
|
||||
});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
await prisma.segment.update({
|
||||
where: {id: segment.id},
|
||||
@@ -284,6 +321,104 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
public static async addContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{added: number; notFound: string[]}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only add contacts to STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email (case-insensitive)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||
|
||||
if (contacts.length > 0) {
|
||||
// Check for existing memberships (to reactivate vs create new)
|
||||
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||
where: {segmentId, contactId: {in: contacts.map(c => c.id)}},
|
||||
select: {contactId: true},
|
||||
});
|
||||
const existingIds = new Set(existingMemberships.map(m => m.contactId));
|
||||
|
||||
const newContactIds = contacts.filter(c => !existingIds.has(c.id)).map(c => c.id);
|
||||
const reEntryIds = contacts.filter(c => existingIds.has(c.id)).map(c => c.id);
|
||||
|
||||
if (newContactIds.length > 0) {
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: newContactIds.map(contactId => ({segmentId, contactId, enteredAt: new Date()})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reEntryIds.length > 0) {
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: reEntryIds}},
|
||||
data: {exitedAt: null, enteredAt: new Date()},
|
||||
});
|
||||
}
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {added: contacts.length, notFound};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
public static async removeContacts(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{removed: number}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
throw new HttpException(400, 'Can only remove contacts from STATIC segments');
|
||||
}
|
||||
|
||||
// Look up contacts by email
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
},
|
||||
select: {id: true},
|
||||
});
|
||||
|
||||
if (contacts.length > 0) {
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {segmentId, contactId: {in: contactIds}, exitedAt: null},
|
||||
data: {exitedAt: new Date()},
|
||||
});
|
||||
|
||||
// Update member count
|
||||
const memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {removed: contacts.length};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or recompute segment membership for all contacts
|
||||
* Now uses cursor-based pagination for memory efficiency with large contact lists
|
||||
@@ -298,7 +433,14 @@ export class SegmentService {
|
||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||
}
|
||||
|
||||
const condition = segment.condition as unknown as FilterCondition;
|
||||
if (segment.type === 'STATIC') {
|
||||
// For static segments, just update the count from memberships — no contact scanning
|
||||
const total = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount: total}});
|
||||
return {added: 0, removed: 0, total};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
|
||||
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
||||
@@ -364,15 +506,43 @@ export class SegmentService {
|
||||
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
|
||||
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
|
||||
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: batch.map(contactId => ({
|
||||
// Check which contacts already have a membership record (inactive)
|
||||
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||
where: {
|
||||
segmentId,
|
||||
contactId,
|
||||
enteredAt: new Date(),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
contactId: {in: batch},
|
||||
},
|
||||
select: {contactId: true},
|
||||
});
|
||||
|
||||
const existingContactIds = new Set(existingMemberships.map(m => m.contactId));
|
||||
const newEntries = batch.filter(id => !existingContactIds.has(id));
|
||||
const reEntries = batch.filter(id => existingContactIds.has(id));
|
||||
|
||||
if (newEntries.length > 0) {
|
||||
await prisma.segmentMembership.createMany({
|
||||
data: newEntries.map(contactId => ({
|
||||
segmentId,
|
||||
contactId,
|
||||
enteredAt: new Date(),
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (reEntries.length > 0) {
|
||||
await prisma.segmentMembership.updateMany({
|
||||
where: {
|
||||
segmentId,
|
||||
contactId: {in: reEntries},
|
||||
},
|
||||
data: {
|
||||
exitedAt: null,
|
||||
enteredAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create segment-specific entry events for each contact in the batch
|
||||
for (const contactId of batch) {
|
||||
try {
|
||||
@@ -496,6 +666,19 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter condition (recursive)
|
||||
*/
|
||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||
|
||||
if (condition.logic === 'AND') {
|
||||
return {AND: groupClauses};
|
||||
} else {
|
||||
return {OR: groupClauses};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter group (recursive)
|
||||
*/
|
||||
@@ -551,8 +734,10 @@ export class SegmentService {
|
||||
'exists',
|
||||
'notExists',
|
||||
'within',
|
||||
'olderThan',
|
||||
'triggered',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggered',
|
||||
];
|
||||
|
||||
@@ -571,7 +756,9 @@ export class SegmentService {
|
||||
'greaterThanOrEqual',
|
||||
'lessThanOrEqual',
|
||||
'within',
|
||||
'olderThan',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
];
|
||||
|
||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||
@@ -579,7 +766,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
// Validate unit for time-based operators
|
||||
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
|
||||
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||
}
|
||||
}
|
||||
@@ -594,19 +781,6 @@ export class SegmentService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter condition (recursive)
|
||||
*/
|
||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||
|
||||
if (condition.logic === 'AND') {
|
||||
return {AND: groupClauses};
|
||||
} else {
|
||||
return {OR: groupClauses};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter group (recursive)
|
||||
*/
|
||||
@@ -709,6 +883,20 @@ export class SegmentService {
|
||||
// Use ISO string for lexicographic comparison in JSON
|
||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
||||
}
|
||||
case 'olderThan': {
|
||||
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||
}
|
||||
|
||||
// Calculate the "before" date (X time units ago from now)
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const before = new Date(now.getTime() - milliseconds);
|
||||
|
||||
// Use ISO string for lexicographic comparison in JSON
|
||||
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
|
||||
}
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
||||
}
|
||||
@@ -802,6 +990,18 @@ export class SegmentService {
|
||||
|
||||
return {[field]: {gte: since}};
|
||||
}
|
||||
case 'olderThan': {
|
||||
// "olderThan X days/hours/minutes" means more than X time units ago
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const before = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {[field]: {lt: before}};
|
||||
}
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
||||
}
|
||||
@@ -901,6 +1101,42 @@ export class SegmentService {
|
||||
};
|
||||
}
|
||||
|
||||
case 'triggeredOlderThan': {
|
||||
// Contact triggered this event, but only more than X time ago (not recently)
|
||||
// This means: has event AND all occurrences are before the cutoff
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const before = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
AND: [
|
||||
// Must have triggered the event at some point
|
||||
{
|
||||
events: {
|
||||
some: {
|
||||
name: eventName,
|
||||
},
|
||||
},
|
||||
},
|
||||
// But NOT within the recent timeframe
|
||||
{
|
||||
events: {
|
||||
none: {
|
||||
name: eventName,
|
||||
createdAt: {
|
||||
gte: before,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'notTriggered':
|
||||
// Contact has never triggered this event
|
||||
return {
|
||||
@@ -975,6 +1211,42 @@ export class SegmentService {
|
||||
};
|
||||
}
|
||||
|
||||
case 'triggeredOlderThan': {
|
||||
// Contact had this email activity, but only more than X time ago (not recently)
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const before = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
AND: [
|
||||
// Must have the email activity at some point
|
||||
{
|
||||
emails: {
|
||||
some: {
|
||||
[field]: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// But NOT within the recent timeframe
|
||||
{
|
||||
emails: {
|
||||
none: {
|
||||
[field]: {
|
||||
gte: before,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case 'notTriggered':
|
||||
// Contact has never had this email activity
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import type {Template} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
export interface PaginatedTemplates {
|
||||
templates: Template[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Get all templates for a project with pagination
|
||||
@@ -23,7 +16,7 @@ export class TemplateService {
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
type?: Template['type'],
|
||||
): Promise<PaginatedTemplates> {
|
||||
): Promise<PaginatedResponse<Template>> {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.TemplateWhereInput = {
|
||||
@@ -51,7 +44,7 @@ export class TemplateService {
|
||||
]);
|
||||
|
||||
return {
|
||||
templates,
|
||||
data: templates,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {Keys} from './keys.js';
|
||||
* Extract base domain from URL for cookie sharing across subdomains
|
||||
* e.g., "http://api.example.com" -> ".example.com"
|
||||
* e.g., "http://api.localhost" -> ".localhost"
|
||||
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||
*/
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (NODE_ENV === 'development') {
|
||||
@@ -28,10 +29,14 @@ function getCookieDomain(): string | undefined {
|
||||
// Extract base domain (last two parts for most domains, or .localhost)
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// For *.localhost or *.local, use the full hostname with leading dot
|
||||
if (hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
||||
// For *.localhost, use .localhost (reserved TLD)
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
return '.localhost';
|
||||
}
|
||||
// For *.local (mDNS TLD), use the actual base domain
|
||||
if (hostname.endsWith('.local')) {
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
// For other domains, use the last two parts (e.g., .example.com)
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type {
|
||||
Contact,
|
||||
Prisma,
|
||||
Template,
|
||||
Workflow,
|
||||
WorkflowExecution,
|
||||
WorkflowStep,
|
||||
WorkflowStepExecution,
|
||||
Template,
|
||||
Workflow,
|
||||
} from '@plunk/db';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
|
||||
// Type aliases for workflow execution context
|
||||
type StepConfig = Prisma.JsonValue;
|
||||
type StepResult = Record<string, unknown>;
|
||||
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
|
||||
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
|
||||
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
|
||||
// This is a delayed step - continue with execution
|
||||
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
|
||||
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
|
||||
signale.info(
|
||||
`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`,
|
||||
);
|
||||
return; // Already completed or cancelled
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
|
||||
data: {
|
||||
status: StepExecutionStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
output: result ? (result as Prisma.InputJsonValue) : undefined,
|
||||
output: result ? toPrismaJson(result) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
|
||||
data: {
|
||||
status: StepExecutionStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
output: {
|
||||
output: toPrismaJson({
|
||||
eventName,
|
||||
eventData: data ? (data as Prisma.InputJsonValue) : undefined,
|
||||
eventData: data ? toPrismaJson(data) : undefined,
|
||||
receivedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -517,18 +519,25 @@ export class WorkflowExecutionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* SEND_EMAIL step - Send an email to the contact
|
||||
* SEND_EMAIL step - Send an email to the contact or a custom recipient
|
||||
*/
|
||||
private static async executeSendEmail(
|
||||
step: WorkflowStepWithTemplate,
|
||||
execution: WorkflowExecutionWithRelations,
|
||||
stepExecution: WorkflowStepExecution,
|
||||
_config: StepConfig,
|
||||
config: StepConfig,
|
||||
): Promise<StepResult> {
|
||||
if (!step.template) {
|
||||
throw new Error('No template configured for SEND_EMAIL step');
|
||||
}
|
||||
|
||||
// Parse step config to get recipient configuration
|
||||
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||
const recipientConfig =
|
||||
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||
: {type: 'CONTACT'};
|
||||
|
||||
// Get contact data for variable substitution
|
||||
const contact = execution.contact;
|
||||
const contactData =
|
||||
@@ -543,6 +552,7 @@ export class WorkflowExecutionService {
|
||||
: {};
|
||||
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
...executionContext,
|
||||
@@ -555,10 +565,20 @@ export class WorkflowExecutionService {
|
||||
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||
|
||||
// Determine recipient email
|
||||
let recipientEmail = contact.email;
|
||||
let recipientContactId = contact.id;
|
||||
|
||||
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||
recipientEmail = recipientConfig.customEmail;
|
||||
// For custom recipients, we don't associate with a contact
|
||||
recipientContactId = contact.id; // Keep original contact for tracking
|
||||
}
|
||||
|
||||
// Send email via EmailService
|
||||
const email = await EmailService.sendWorkflowEmail({
|
||||
projectId: execution.workflow.projectId,
|
||||
contactId: contact.id,
|
||||
contactId: recipientContactId,
|
||||
workflowExecutionId: execution.id,
|
||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||
templateId: step.template.id,
|
||||
@@ -567,11 +587,15 @@ export class WorkflowExecutionService {
|
||||
from: step.template.from,
|
||||
fromName: step.template.fromName || undefined,
|
||||
replyTo: step.template.replyTo || undefined,
|
||||
// Pass custom recipient email if specified
|
||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
emailId: email.id,
|
||||
sentAt: email.createdAt,
|
||||
recipientType: recipientConfig.type,
|
||||
recipientEmail,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -866,7 +890,7 @@ export class WorkflowExecutionService {
|
||||
await prisma.contact.update({
|
||||
where: {id: contact.id},
|
||||
data: {
|
||||
data: newData ? (newData as Prisma.InputJsonValue) : undefined,
|
||||
data: newData ? toPrismaJson(newData) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
|
||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -10,34 +12,16 @@ import {EventService} from './EventService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||
|
||||
export interface PaginatedWorkflows {
|
||||
workflows: Workflow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface WorkflowWithDetails extends Workflow {
|
||||
steps: (WorkflowStep & {
|
||||
template?: {id: string; name: string} | null;
|
||||
outgoingTransitions: WorkflowTransition[];
|
||||
incomingTransitions: WorkflowTransition[];
|
||||
})[];
|
||||
}
|
||||
|
||||
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
|
||||
workflow: Workflow;
|
||||
contact: {id: string; email: string};
|
||||
currentStep?: WorkflowStep | null;
|
||||
stepExecutions: WorkflowStepExecution[];
|
||||
}
|
||||
|
||||
export class WorkflowService {
|
||||
/**
|
||||
* Get all workflows for a project with pagination
|
||||
*/
|
||||
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
|
||||
public static async list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
): Promise<PaginatedResponse<Workflow>> {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.WorkflowWhereInput = {
|
||||
@@ -71,7 +55,7 @@ export class WorkflowService {
|
||||
]);
|
||||
|
||||
return {
|
||||
workflows: workflows as Workflow[],
|
||||
data: workflows as Workflow[],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
@@ -210,25 +194,57 @@ export class WorkflowService {
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||
// Use transaction to update workflow and TRIGGER step atomically
|
||||
const updated = await prisma.$transaction(async tx => {
|
||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||
if (data.triggerConfig !== undefined) {
|
||||
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
||||
}
|
||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.description !== undefined) updateData.description = data.description;
|
||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||
if (data.triggerConfig !== undefined) {
|
||||
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
||||
}
|
||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||
|
||||
const updated = await prisma.workflow.update({
|
||||
where: {id: workflowId},
|
||||
data: updateData,
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
const updatedWorkflow = await tx.workflow.update({
|
||||
where: {id: workflowId},
|
||||
data: updateData,
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||
const eventName = newTriggerConfig?.eventName;
|
||||
|
||||
if (eventName) {
|
||||
// Find the TRIGGER step
|
||||
const triggerStep = await tx.workflowStep.findFirst({
|
||||
where: {
|
||||
workflowId: workflowId,
|
||||
type: 'TRIGGER',
|
||||
},
|
||||
});
|
||||
|
||||
if (triggerStep) {
|
||||
// Update TRIGGER step config and name to match
|
||||
await tx.workflowStep.update({
|
||||
where: {id: triggerStep.id},
|
||||
data: {
|
||||
name: `Trigger: ${eventName}`,
|
||||
config: {eventName},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updatedWorkflow;
|
||||
});
|
||||
|
||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||
@@ -236,6 +252,11 @@ export class WorkflowService {
|
||||
await EventService.invalidateWorkflowCache(projectId);
|
||||
}
|
||||
|
||||
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||
await EventService.invalidateWorkflowCache(projectId);
|
||||
}
|
||||
|
||||
// Send notification if enabled status changed
|
||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||
if (data.enabled) {
|
||||
@@ -321,8 +342,8 @@ export class WorkflowService {
|
||||
workflowId,
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
position: data.position as Prisma.InputJsonValue,
|
||||
config: data.config as Prisma.InputJsonValue,
|
||||
position: toPrismaJson(data.position),
|
||||
config: toPrismaJson(data.config),
|
||||
templateId: data.templateId,
|
||||
},
|
||||
});
|
||||
@@ -403,8 +424,8 @@ export class WorkflowService {
|
||||
const updateData: Prisma.WorkflowStepUpdateInput = {};
|
||||
|
||||
if (data.name !== undefined) updateData.name = data.name;
|
||||
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
|
||||
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
|
||||
if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
|
||||
if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
|
||||
if (data.templateId !== undefined) {
|
||||
if (data.templateId === null) {
|
||||
updateData.template = {disconnect: true};
|
||||
@@ -596,7 +617,7 @@ export class WorkflowService {
|
||||
fromStepId: data.fromStepId,
|
||||
condition: {
|
||||
path: ['branch'],
|
||||
equals: conditionObj.branch as Prisma.InputJsonValue,
|
||||
equals: toPrismaJson(conditionObj.branch),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import {BillingLimitService} from '../BillingLimitService';
|
||||
import {EmailService} from '../EmailService';
|
||||
@@ -320,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create a date in the previous month
|
||||
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
|
||||
const lastMonth = new Date();
|
||||
lastMonth.setDate(1);
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
|
||||
await prisma.email.create({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignService} from '../CampaignService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('CampaignService', () => {
|
||||
|
||||
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
|
||||
|
||||
expect(result.campaigns).toHaveLength(10);
|
||||
expect(result.data).toHaveLength(10);
|
||||
expect(result.total).toBe(25);
|
||||
expect(result.totalPages).toBe(3);
|
||||
expect(result.page).toBe(1);
|
||||
@@ -177,8 +177,8 @@ describe('CampaignService', () => {
|
||||
|
||||
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
|
||||
|
||||
expect(result.campaigns).toHaveLength(2);
|
||||
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {ContactService} from '../ContactService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
|
||||
await ContactService.bulkSubscribe(project1.id, [
|
||||
p1Contact1.id,
|
||||
p1Contact2.id,
|
||||
p2Contact1.id,
|
||||
p2Contact2.id,
|
||||
]);
|
||||
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
||||
|
||||
const p1ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project1.id},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {DomainService} from '../DomainService.js';
|
||||
import {HttpException} from '../../exceptions/index.js';
|
||||
@@ -110,9 +110,7 @@ describe('DomainService', () => {
|
||||
it('should throw error for invalid email format', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
@@ -122,13 +120,13 @@ describe('DomainService', () => {
|
||||
it('should throw error when domain is not registered', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain belongs to different project', async () => {
|
||||
@@ -141,13 +139,11 @@ describe('DomainService', () => {
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
||||
).rejects.toThrow(/belongs to a different project/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
|
||||
/belongs to a different project/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain is not verified', async () => {
|
||||
@@ -155,13 +151,11 @@ describe('DomainService', () => {
|
||||
|
||||
await DomainService.addDomain(project.id, 'unverified.com');
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(HttpException);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not verified/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not verified/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return domain when all checks pass', async () => {
|
||||
@@ -358,9 +352,9 @@ describe('DomainService', () => {
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(
|
||||
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
|
||||
).rejects.toThrow(/domain not found/i);
|
||||
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,9 +385,7 @@ describe('DomainService', () => {
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
||||
/used in.*template/i,
|
||||
);
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
|
||||
});
|
||||
|
||||
it('should throw error when domain is used in active campaigns', async () => {
|
||||
@@ -409,9 +401,7 @@ describe('DomainService', () => {
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
||||
/used in.*campaign/i,
|
||||
);
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
|
||||
});
|
||||
|
||||
it('should allow removal when campaign is SENT (completed)', async () => {
|
||||
@@ -433,9 +423,9 @@ describe('DomainService', () => {
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(
|
||||
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
|
||||
).rejects.toThrow(/domain not found/i);
|
||||
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should check usage in multiple templates', async () => {
|
||||
@@ -492,17 +482,15 @@ describe('DomainService', () => {
|
||||
expect(result.domain).toBe('mail.example.com');
|
||||
|
||||
// Different subdomain should fail
|
||||
await expect(
|
||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
||||
).rejects.toThrow(/not registered/i);
|
||||
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with no @ sign', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
|
||||
it('should handle email with multiple @ signs', async () => {
|
||||
@@ -516,9 +504,7 @@ describe('DomainService', () => {
|
||||
it('should handle empty email string', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,11 +523,7 @@ describe('DomainService', () => {
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.map(d => d.domain).sort()).toEqual([
|
||||
'concurrent1.com',
|
||||
'concurrent2.com',
|
||||
'concurrent3.com',
|
||||
]);
|
||||
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
|
||||
});
|
||||
|
||||
it('should handle concurrent ownership checks', async () => {
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import {EmailService} from '../EmailService';
|
||||
import {sendRawEmail} from '../SESService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock SES service
|
||||
// Mock AWS SDK globally (used by real SESService calls in MIME tests)
|
||||
vi.mock('@aws-sdk/client-ses', () => {
|
||||
const SESMock = vi.fn();
|
||||
SESMock.prototype.sendRawEmail = vi.fn().mockResolvedValue({MessageId: 'test-message-id'});
|
||||
return {SES: SESMock};
|
||||
});
|
||||
|
||||
// Mock constants to provide AWS credentials for SESService, preserving other exports
|
||||
vi.mock('../../app/constants.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../app/constants.js')>();
|
||||
return {
|
||||
...actual,
|
||||
AWS_SES_ACCESS_KEY_ID: 'test-key-id',
|
||||
AWS_SES_REGION: 'us-east-1',
|
||||
AWS_SES_SECRET_ACCESS_KEY: 'test-secret',
|
||||
SES_CONFIGURATION_SET: 'test-config-set',
|
||||
SES_CONFIGURATION_SET_NO_TRACKING: 'test-no-tracking-set',
|
||||
TRACKING_TOGGLE_ENABLED: true,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock SES service (default behavior for most tests)
|
||||
vi.mock('../SESService', () => ({
|
||||
sendRawEmail: vi.fn(),
|
||||
}));
|
||||
@@ -801,5 +822,204 @@ describe('EmailService', () => {
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept inline attachment with contentId', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
from: '[email protected]',
|
||||
subject: 'Inline Image',
|
||||
body: '<img src="cid:logo" />',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'logo.png',
|
||||
content: Buffer.from('image').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'logo',
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const attachment = result.data.attachments![0];
|
||||
expect(attachment.contentId).toBe('logo');
|
||||
expect(attachment.disposition).toBe('inline');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject contentId exceeding 255 chars', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
contentId: 'a'.repeat(256),
|
||||
disposition: 'inline',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid disposition', () => {
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
to: '[email protected]',
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content: Buffer.from('content').toString('base64'),
|
||||
contentType: 'image/png',
|
||||
disposition: 'invalid-disposition',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SES MIME BOUNDARY STRUCTURE
|
||||
// ========================================
|
||||
// These tests verify the raw MIME assembly logic inside sendRawEmail.
|
||||
// They need the REAL sendRawEmail (not the mock above), so we mock
|
||||
// at the AWS SDK level instead.
|
||||
|
||||
describe('SES MIME Boundary Structure', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for mixed content (attachments)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
expect(ses.sendRawEmail).toHaveBeenCalled();
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Mixed -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
expect(rawMessage).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
|
||||
const mixedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedBoundaryMatch ? mixedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly structure MIME boundaries for related content (inline images)', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Verify boundary hierarchy: Related -> Alternative
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/related; boundary="([^"]+)"/s);
|
||||
|
||||
const relatedBoundaryMatch = rawMessage.match(/boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedBoundaryMatch ? relatedBoundaryMatch[1] : '';
|
||||
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
expect(rawMessage).toContain(`Content-Disposition: inline; filename="image.png"`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
});
|
||||
|
||||
it('should correctly nest mixed > related > alternative boundaries', async () => {
|
||||
const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual<typeof import('../SESService')>('../SESService');
|
||||
|
||||
const params = {
|
||||
from: {name: 'Sender', email: '[email protected]'},
|
||||
to: ['[email protected]'],
|
||||
content: {subject: 'Test Subject', html: '<p>Hello world <img src="cid:image1"></p>'},
|
||||
attachments: [
|
||||
{
|
||||
filename: 'test.txt',
|
||||
content: 'SGVsbG8=',
|
||||
contentType: 'text/plain',
|
||||
disposition: 'attachment' as const,
|
||||
},
|
||||
{
|
||||
filename: 'image.png',
|
||||
content:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
contentType: 'image/png',
|
||||
contentId: 'image1',
|
||||
disposition: 'inline' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await realSendRawEmail(params);
|
||||
|
||||
const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0];
|
||||
const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data);
|
||||
|
||||
// Root should be mixed
|
||||
expect(rawMessage).toMatch(/^From:.*Content-Type: multipart\/mixed; boundary="([^"]+)"/s);
|
||||
|
||||
const mixedMatch = rawMessage.match(/Content-Type: multipart\/mixed; boundary="([^"]+)"/);
|
||||
const mixedBoundary = mixedMatch ? mixedMatch[1] : 'NOT_FOUND_MIXED';
|
||||
|
||||
// Within mixed, we should find related
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}\nContent-Type: multipart/related`);
|
||||
|
||||
const relatedMatch = rawMessage.match(/Content-Type: multipart\/related; boundary="([^"]+)"/);
|
||||
const relatedBoundary = relatedMatch ? relatedMatch[1] : 'NOT_FOUND_RELATED';
|
||||
|
||||
// Within related, we should find alternative
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}\nContent-Type: multipart/alternative`);
|
||||
|
||||
const altMatch = rawMessage.match(/Content-Type: multipart\/alternative; boundary="([^"]+)"/);
|
||||
const altBoundary = altMatch ? altMatch[1] : 'NOT_FOUND_ALT';
|
||||
|
||||
// Verify all closing boundaries exist
|
||||
expect(rawMessage).toContain(`--${altBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${relatedBoundary}--`);
|
||||
expect(rawMessage).toContain(`--${mixedBoundary}--`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match exact string values in standard fields (case-insensitive)', async () => {
|
||||
@@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match boolean values', async () => {
|
||||
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match numeric values as strings in JSON fields', async () => {
|
||||
@@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should exclude boolean false values', async () => {
|
||||
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
|
||||
@@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
// notEquals only matches where field exists and has different value
|
||||
expect(ids).toContain(withDifferentValue.id);
|
||||
expect(ids).not.toContain(withMatchingField.id);
|
||||
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match substring in email field (case-insensitive)', async () => {
|
||||
@@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not match when field does not exist', async () => {
|
||||
@@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match partial domain in email', async () => {
|
||||
@@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(gmailUser.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(gmailUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
|
||||
@@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
// notContains only matches where field exists and doesn't contain substring
|
||||
expect(ids).toContain(withDifferentValue.id);
|
||||
expect(ids).not.toContain(withMatchingSubstring.id);
|
||||
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(high.id);
|
||||
expect(ids).toContain(veryHigh.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude values equal to threshold', async () => {
|
||||
@@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should work with negative numbers', async () => {
|
||||
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should work with decimal values', async () => {
|
||||
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(equal.id);
|
||||
expect(ids).toContain(greater.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(low.id);
|
||||
expect(ids).toContain(veryLow.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should exclude values equal to threshold', async () => {
|
||||
@@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(equal.id);
|
||||
expect(ids).toContain(less.id);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(positive.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(positive.id);
|
||||
});
|
||||
|
||||
it('should handle very large numbers', async () => {
|
||||
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withField.id);
|
||||
});
|
||||
|
||||
it('should exclude contacts where field is null', async () => {
|
||||
@@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withValue.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withValue.id);
|
||||
});
|
||||
|
||||
it('should match fields with empty string values', async () => {
|
||||
@@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withEmptyString.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withEmptyString.id);
|
||||
});
|
||||
|
||||
it('should match fields with zero values', async () => {
|
||||
@@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withZero.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withZero.id);
|
||||
});
|
||||
|
||||
it('should match fields with boolean false values', async () => {
|
||||
@@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withFalse.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withFalse.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withoutField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withoutField.id);
|
||||
});
|
||||
|
||||
it('should match contacts where field is null', async () => {
|
||||
@@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withNull.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withNull.id);
|
||||
});
|
||||
|
||||
it('should exclude fields with empty string values', async () => {
|
||||
@@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should exclude fields with zero values', async () => {
|
||||
@@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(recent.id);
|
||||
});
|
||||
|
||||
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(veryRecent.id);
|
||||
});
|
||||
|
||||
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(justNow.id);
|
||||
});
|
||||
});
|
||||
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with JSON date field within specified hours', async () => {
|
||||
@@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with JSON date field within specified minutes', async () => {
|
||||
@@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should NOT match contacts with JSON date field outside the time range', async () => {
|
||||
@@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should NOT match contacts where JSON date field does not exist', async () => {
|
||||
@@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null values in JSON date fields gracefully', async () => {
|
||||
@@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should work correctly with combined filters (AND logic)', async () => {
|
||||
@@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should require unit parameter for within operator', async () => {
|
||||
@@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(newer.id);
|
||||
expect(ids).not.toContain(older.id);
|
||||
});
|
||||
@@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(first.id);
|
||||
expect(ids).toContain(second.id);
|
||||
expect(ids).not.toContain(third.id);
|
||||
@@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match.id);
|
||||
});
|
||||
|
||||
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(match1.id);
|
||||
expect(ids).toContain(match2.id);
|
||||
expect(ids).not.toContain(noMatch.id);
|
||||
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should combine existence checks with value comparisons', async () => {
|
||||
@@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should not match contacts who have not triggered the event', async () => {
|
||||
@@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contacts with multiple occurrences of the same event', async () => {
|
||||
@@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with event within time range (hours)', async () => {
|
||||
@@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should match contacts with event within time range (minutes)', async () => {
|
||||
@@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should handle events at exact boundary', async () => {
|
||||
@@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
// Should not match because events at exact boundary are excluded
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not match contacts with event outside time range', async () => {
|
||||
@@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contact if any of their events is within range', async () => {
|
||||
@@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should not match contacts who have triggered the event', async () => {
|
||||
@@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should match contacts with other events but not the target event', async () => {
|
||||
@@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should combine multiple event filters', async () => {
|
||||
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(subscribed.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(subscribed.id);
|
||||
});
|
||||
|
||||
it('should filter contacts by custom data fields', async () => {
|
||||
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(proUser.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(proUser.id);
|
||||
});
|
||||
|
||||
it('should filter contacts with multiple conditions', async () => {
|
||||
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(target.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(target.id);
|
||||
});
|
||||
|
||||
it('should support notEquals operator', async () => {
|
||||
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(pro.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(pro.id);
|
||||
});
|
||||
|
||||
it('should support contains operator for strings', async () => {
|
||||
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(match.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(match.id);
|
||||
});
|
||||
|
||||
it('should support exists operator for custom fields', async () => {
|
||||
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(withField.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(withField.id);
|
||||
});
|
||||
|
||||
it('should handle empty segments', async () => {
|
||||
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.contacts).toHaveLength(2);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
|
||||
expect(page1.contacts).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
|
||||
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
|
||||
expect(page2.contacts).toHaveLength(10);
|
||||
expect(page2.data).toHaveLength(10);
|
||||
|
||||
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
|
||||
expect(page3.contacts).toHaveLength(5);
|
||||
expect(page3.data).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Initially not in segment
|
||||
let result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
|
||||
// Update contact to pro plan
|
||||
await prisma.contact.update({
|
||||
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
|
||||
|
||||
// Should now be in segment
|
||||
result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.contacts[0].id).toBe(contact.id);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe(contact.id);
|
||||
});
|
||||
|
||||
it('should be removed from segment when criteria no longer met', async () => {
|
||||
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Initially in segment
|
||||
let result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
|
||||
// Unsubscribe contact
|
||||
await prisma.contact.update({
|
||||
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
|
||||
|
||||
// Should no longer be in segment
|
||||
result = await SegmentService.getContacts(projectId, segment.id);
|
||||
expect(result.contacts).toHaveLength(0);
|
||||
expect(result.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -506,8 +506,8 @@ describe('SegmentService', () => {
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
|
||||
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
|
||||
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
|
||||
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
|
||||
expect(result.data.map(c => c.id)).not.toContain(match.id);
|
||||
});
|
||||
|
||||
it('should support case-insensitive equals/contains for email strings', async () => {
|
||||
@@ -526,7 +526,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
|
||||
const equalsIds = equalsResult.contacts.map(c => c.id);
|
||||
const equalsIds = equalsResult.data.map(c => c.id);
|
||||
expect(equalsIds).toContain(lower.id);
|
||||
expect(equalsIds).toContain(upper.id);
|
||||
|
||||
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
|
||||
const containsIds = containsResult.contacts.map(c => c.id);
|
||||
const containsIds = containsResult.data.map(c => c.id);
|
||||
expect(containsIds).toContain(lower.id);
|
||||
expect(containsIds).toContain(upper.id);
|
||||
});
|
||||
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
|
||||
expect(ids).toContain(unsubscribed.id);
|
||||
expect(ids).not.toContain(subscribed.id);
|
||||
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
|
||||
const notContainsIds = notContainsResult.contacts.map(c => c.id);
|
||||
const notContainsIds = notContainsResult.data.map(c => c.id);
|
||||
expect(notContainsIds).toContain(other.id);
|
||||
expect(notContainsIds).not.toContain(acme.id);
|
||||
|
||||
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
|
||||
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
|
||||
const notEqualsIds = notEqualsResult.data.map(c => c.id);
|
||||
expect(notEqualsIds).toContain(other.id);
|
||||
expect(notEqualsIds).not.toContain(acme.id);
|
||||
});
|
||||
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
|
||||
const existsIds = new Set(existsResult.contacts.map(c => c.id));
|
||||
const existsIds = new Set(existsResult.data.map(c => c.id));
|
||||
expect(existsIds.has(withCompany.id)).toBe(true);
|
||||
expect(existsIds.has(withNullCompany.id)).toBe(false);
|
||||
|
||||
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
|
||||
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
|
||||
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
|
||||
expect(notExistsIds.has(withCompany.id)).toBe(false);
|
||||
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
|
||||
});
|
||||
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
|
||||
const gtIds = greaterThanResult.contacts.map(c => c.id);
|
||||
const gtIds = greaterThanResult.data.map(c => c.id);
|
||||
expect(gtIds).toContain(mid.id);
|
||||
expect(gtIds).toContain(high.id);
|
||||
expect(gtIds).not.toContain(low.id);
|
||||
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
|
||||
const lteIds = lteResult.contacts.map(c => c.id);
|
||||
const lteIds = lteResult.data.map(c => c.id);
|
||||
expect(lteIds).toContain(low.id);
|
||||
expect(lteIds).toContain(mid.id);
|
||||
expect(lteIds).not.toContain(high.id);
|
||||
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
|
||||
const gtIds = gtResult.contacts.map(c => c.id);
|
||||
const gtIds = gtResult.data.map(c => c.id);
|
||||
expect(gtIds).toContain(newer.id);
|
||||
expect(gtIds).not.toContain(older.id);
|
||||
|
||||
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
|
||||
const lteIds = lteResult.contacts.map(c => c.id);
|
||||
const lteIds = lteResult.data.map(c => c.id);
|
||||
expect(lteIds).toContain(older.id);
|
||||
expect(lteIds).toContain(newer.id);
|
||||
});
|
||||
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
|
||||
});
|
||||
|
||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||
const ids = result.contacts.map(c => c.id);
|
||||
const ids = result.data.map(c => c.id);
|
||||
expect(ids).toContain(recent.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {TemplateType} from '@plunk/db';
|
||||
import {TemplateService} from '../TemplateService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
|
||||
}
|
||||
|
||||
const page1 = await TemplateService.list(projectId, 1, 10);
|
||||
expect(page1.templates).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.page).toBe(1);
|
||||
expect(page1.pageSize).toBe(10);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
|
||||
const page2 = await TemplateService.list(projectId, 2, 10);
|
||||
expect(page2.templates).toHaveLength(10);
|
||||
expect(page2.data).toHaveLength(10);
|
||||
expect(page2.page).toBe(2);
|
||||
|
||||
const page3 = await TemplateService.list(projectId, 3, 10);
|
||||
expect(page3.templates).toHaveLength(5);
|
||||
expect(page3.data).toHaveLength(5);
|
||||
expect(page3.page).toBe(3);
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter templates by search query (description)', async () => {
|
||||
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'new');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.templates.map(t => t.description)).toEqual(
|
||||
expect(result.data.map(t => t.description)).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('new')]),
|
||||
);
|
||||
});
|
||||
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
|
||||
|
||||
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
||||
expect(marketingResult.total).toBe(2);
|
||||
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
||||
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
||||
|
||||
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
||||
expect(transactionalResult.total).toBe(1);
|
||||
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||
});
|
||||
|
||||
it('should combine search and type filters', async () => {
|
||||
@@ -223,7 +223,7 @@ describe('TemplateService', () => {
|
||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.templates[0].name).toBe('Welcome Email');
|
||||
expect(result.data[0].name).toBe('Welcome Email');
|
||||
});
|
||||
|
||||
it('should return templates ordered by creation date (newest first)', async () => {
|
||||
@@ -236,9 +236,9 @@ describe('TemplateService', () => {
|
||||
|
||||
const result = await TemplateService.list(projectId, 1, 20);
|
||||
|
||||
expect(result.templates[0].id).toBe(template3.id); // Newest
|
||||
expect(result.templates[1].id).toBe(template2.id);
|
||||
expect(result.templates[2].id).toBe(template1.id); // Oldest
|
||||
expect(result.data[0].id).toBe(template3.id); // Newest
|
||||
expect(result.data[1].id).toBe(template2.id);
|
||||
expect(result.data[2].id).toBe(template1.id); // Oldest
|
||||
});
|
||||
|
||||
it('should only return templates for the specified project', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
||||
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium Status',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.isPremium',
|
||||
operator: 'equals',
|
||||
value: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Premium Path',
|
||||
position: {x: 200, y: -50},
|
||||
config: {reason: 'Premium customer'},
|
||||
config: toPrismaJson({reason: 'Premium customer'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Standard Path',
|
||||
position: {x: 200, y: 50},
|
||||
config: {reason: 'Standard customer'},
|
||||
config: toPrismaJson({reason: 'Standard customer'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
priority: 2,
|
||||
},
|
||||
});
|
||||
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.isPremium',
|
||||
operator: 'equals',
|
||||
value: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Premium',
|
||||
position: {x: 200, y: -50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Standard',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: yesStep.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Country',
|
||||
position: {x: 100, y: 0},
|
||||
config: {field: 'data.country', operator: 'equals', value: 'US'},
|
||||
config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Premium (US)',
|
||||
position: {x: 200, y: -50},
|
||||
config: {field: 'data.isPremium', operator: 'equals', value: true},
|
||||
config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Premium',
|
||||
position: {x: 300, y: -75},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'US Standard',
|
||||
position: {x: 300, y: -25},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Non-US',
|
||||
position: {x: 200, y: 50},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: condition2.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition1.id,
|
||||
toStepId: nonUsExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usPremiumExit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition2.id,
|
||||
toStepId: usStandardExit.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||
name: 'Wait for Purchase',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
eventName: 'purchase.completed',
|
||||
timeout: 3600, // 1 hour
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||
name: 'Wait for Event',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
eventName: 'user.verified',
|
||||
timeout: 3600,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Wait 1 day',
|
||||
position: {x: 100, y: 0},
|
||||
config: {amount: 1, unit: 'days'},
|
||||
config: toPrismaJson({amount: 1, unit: 'days'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Status',
|
||||
position: {x: 200, y: 0},
|
||||
config: {field: 'contact.subscribed', operator: 'equals', value: true},
|
||||
config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Complete',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: exit.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'A/B Split',
|
||||
position: {x: 100, y: 0},
|
||||
config: {field: 'data.segment', operator: 'equals', value: 'A'},
|
||||
config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Path A Delay',
|
||||
position: {x: 200, y: -50},
|
||||
config: {amount: 1, unit: 'hours'},
|
||||
config: toPrismaJson({amount: 1, unit: 'hours'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.DELAY,
|
||||
name: 'Path B Delay',
|
||||
position: {x: 200, y: 50},
|
||||
config: {amount: 2, unit: 'hours'},
|
||||
config: toPrismaJson({amount: 2, unit: 'hours'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Merge Point',
|
||||
position: {x: 300, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathA.id,
|
||||
condition: {branch: 'yes'},
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: pathB.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Bad Condition',
|
||||
position: {x: 100, y: 0},
|
||||
config: {}, // Invalid - missing required fields
|
||||
config: toPrismaJson({}), // Invalid - missing required fields
|
||||
},
|
||||
});
|
||||
|
||||
@@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check Missing Field',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'data.nonExistentField',
|
||||
operator: 'equals',
|
||||
value: 'something',
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Exit',
|
||||
position: {x: 200, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: condition.id,
|
||||
toStepId: noStep.id,
|
||||
condition: {branch: 'no'},
|
||||
condition: toPrismaJson({branch: 'no'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Early Exit',
|
||||
position: {x: 100, y: 0},
|
||||
config: {reason: 'User already converted'},
|
||||
config: toPrismaJson({reason: 'User already converted'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: {},
|
||||
context: toPrismaJson({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.TRIGGER,
|
||||
name: 'Start',
|
||||
position: {x: 0, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
});
|
||||
|
||||
const exitStep = await factories.createWorkflowStep({
|
||||
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'End',
|
||||
position: {x: 100, y: 0},
|
||||
config: {},
|
||||
config: toPrismaJson({}),
|
||||
});
|
||||
|
||||
await prisma.workflowTransition.create({
|
||||
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep.id,
|
||||
context: contextData as Prisma.InputJsonValue,
|
||||
context: toPrismaJson(contextData),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check if first open',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.isFirstOpen',
|
||||
operator: 'equals',
|
||||
value: true, // Use boolean, not string
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'First Open',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'first_open'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'first_open'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Not First Open',
|
||||
position: {x: 200, y: 100},
|
||||
config: {reason: 'not_first_open'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'not_first_open'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
|
||||
});
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue},
|
||||
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})},
|
||||
});
|
||||
await prisma.workflowTransition.create({
|
||||
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue},
|
||||
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})},
|
||||
});
|
||||
|
||||
// Create execution with event data
|
||||
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome Email',
|
||||
from: '[email protected]',
|
||||
isFirstOpen: true,
|
||||
openedAt: new Date().toISOString(),
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check subject',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.subject',
|
||||
operator: 'contains',
|
||||
value: 'Welcome',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'matched'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'matched'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome to Plunk!',
|
||||
from: '[email protected]',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.CONDITION,
|
||||
name: 'Check opens count',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
field: 'event.opens',
|
||||
operator: 'greaterThan',
|
||||
value: '3',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.EXIT,
|
||||
name: 'Done',
|
||||
position: {x: 200, y: 0},
|
||||
config: {reason: 'engaged'} as Prisma.InputJsonValue,
|
||||
config: toPrismaJson({reason: 'engaged'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
data: {
|
||||
fromStepId: conditionStep.id,
|
||||
toStepId: exitStep.id,
|
||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
||||
condition: toPrismaJson({branch: 'yes'}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Newsletter',
|
||||
opens: 5,
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
type: WorkflowStepType.WEBHOOK,
|
||||
name: 'Send Webhook',
|
||||
position: {x: 100, y: 0},
|
||||
config: {
|
||||
config: toPrismaJson({
|
||||
url: 'https://webhook.example.com/test',
|
||||
method: 'POST',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
||||
contactId: contact.id,
|
||||
status: WorkflowExecutionStatus.RUNNING,
|
||||
currentStepId: triggerStep!.id,
|
||||
context: {
|
||||
context: toPrismaJson({
|
||||
subject: 'Welcome Email',
|
||||
from: '[email protected]',
|
||||
messageId: 'msg-123',
|
||||
isFirstOpen: true,
|
||||
openedAt: '2024-01-15T10:00:00Z',
|
||||
} as Prisma.InputJsonValue,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {
|
||||
WorkflowStepType,
|
||||
StepExecutionStatus,
|
||||
WorkflowExecutionStatus,
|
||||
TemplateType,
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowStepType,
|
||||
WorkflowTriggerType,
|
||||
} from '@plunk/db';
|
||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||
|
||||
@@ -205,7 +205,7 @@ describe('WorkflowService', () => {
|
||||
|
||||
const page1 = await WorkflowService.list(projectId, 1, 10);
|
||||
|
||||
expect(page1.workflows).toHaveLength(10);
|
||||
expect(page1.data).toHaveLength(10);
|
||||
expect(page1.total).toBe(25);
|
||||
expect(page1.totalPages).toBe(3);
|
||||
});
|
||||
@@ -218,7 +218,7 @@ describe('WorkflowService', () => {
|
||||
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
|
||||
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include step and execution counts', async () => {
|
||||
@@ -234,8 +234,8 @@ describe('WorkflowService', () => {
|
||||
|
||||
const result = await WorkflowService.list(projectId);
|
||||
|
||||
const found = result.workflows.find(w => w.id === workflow.id) as
|
||||
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}})
|
||||
const found = result.data.find(w => w.id === workflow.id) as
|
||||
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
|
||||
| undefined;
|
||||
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
|
||||
expect(found?._count.executions).toBe(1);
|
||||
|
||||
@@ -26,6 +26,12 @@ export const Keys = {
|
||||
project(projectId: string): string {
|
||||
return `domain:project:${projectId}`;
|
||||
},
|
||||
verifiedEmail(domainId: string): string {
|
||||
return `domain:verified_email:${domainId}`;
|
||||
},
|
||||
unverifiedEmail(domainId: string, year: number, month: string): string {
|
||||
return `domain:unverified_email:${domainId}:${year}-${month}`;
|
||||
},
|
||||
},
|
||||
Billing: {
|
||||
usage(projectId: string, sourceType: string, year: number, month: string): string {
|
||||
@@ -64,4 +70,29 @@ export const Keys = {
|
||||
return `workflows:enabled:${projectId}`;
|
||||
},
|
||||
},
|
||||
Membership: {
|
||||
access(userId: string, projectId: string): string {
|
||||
return `membership:access:${userId}:${projectId}`;
|
||||
},
|
||||
admin(userId: string, projectId: string): string {
|
||||
return `membership:admin:${userId}:${projectId}`;
|
||||
},
|
||||
full(userId: string, projectId: string): string {
|
||||
return `membership:full:${userId}:${projectId}`;
|
||||
},
|
||||
owner(projectId: string): string {
|
||||
return `membership:owner:${projectId}`;
|
||||
},
|
||||
},
|
||||
Project: {
|
||||
id(id: string): string {
|
||||
return `project:id:${id}`;
|
||||
},
|
||||
secret(key: string): string {
|
||||
return `project:secret:${key}`;
|
||||
},
|
||||
public(key: string): string {
|
||||
return `project:public:${key}`;
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||
generateRobotsTxt: true,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.10",
|
||||
"next": "^16.1.5",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -128,6 +128,11 @@ export default function Footer() {
|
||||
Terms
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||
DPA
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||
}: GuideLayoutProps) {
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
||||
|
||||
// Generate breadcrumb items
|
||||
const breadcrumbItems = [
|
||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||
{position: 3, name: title, item: canonical || ''},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react';
|
||||
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Forward,
|
||||
Info,
|
||||
Mail,
|
||||
Server,
|
||||
Shield,
|
||||
Trash2,
|
||||
User,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||
|
||||
interface EmailVerificationResultProps {
|
||||
result: VerificationResult;
|
||||
@@ -41,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
||||
<Server className="h-5 w-5 text-neutral-600" />
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Domain Exists</p>
|
||||
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
||||
<p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.domainExists ? (
|
||||
@@ -67,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Website/A Records */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-neutral-600" />
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Has Website</p>
|
||||
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.hasWebsite ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<Info className="h-5 w-5 text-neutral-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disposable Email */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -83,6 +111,38 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Personal Email */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-5 w-5 text-neutral-600" />
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Personal Email</p>
|
||||
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.isPersonalEmail ? (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<span className="text-sm text-neutral-500">No</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alias/Forwarding Email */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Forward className="h-5 w-5 text-neutral-600" />
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900">Forwarding Service</p>
|
||||
<p className="text-sm text-neutral-600">Email alias/forwarding detected</p>
|
||||
</div>
|
||||
</div>
|
||||
{result.isAlias ? (
|
||||
<Info className="h-5 w-5 text-blue-600" />
|
||||
) : (
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Typo Detection */}
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
|
||||
const fragment = convertToEmailHtml(html);
|
||||
return wrapEmailHtml(fragment);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
export interface EmailVerificationResult {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isTypo: boolean;
|
||||
isPlusAddressed: boolean;
|
||||
domainExists: boolean;
|
||||
hasMxRecords: boolean;
|
||||
suggestedEmail?: string;
|
||||
reasons: string[];
|
||||
}
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
|
||||
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const response = await fetch('/api/verify-email', {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
|
||||
import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Educational content for the email verification tool
|
||||
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
|
||||
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
title: 'Personal Email Detection',
|
||||
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: 'Plus Addressing',
|
||||
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
import {UtilitySchemas} from '@plunk/shared';
|
||||
import {API_URI} from '../../lib/constants';
|
||||
|
||||
interface VerifyEmailResponse {
|
||||
email: string;
|
||||
valid: boolean;
|
||||
isDisposable: boolean;
|
||||
isTypo: boolean;
|
||||
isPlusAddressed: boolean;
|
||||
domainExists: boolean;
|
||||
hasMxRecords: boolean;
|
||||
suggestedEmail?: string;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
|
||||
res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
|
||||
) {
|
||||
// Only allow POST requests
|
||||
if (req.method !== 'POST') {
|
||||
@@ -49,7 +38,7 @@ export default async function handler(
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${secretKey}`,
|
||||
'Authorization': `Bearer ${secretKey}`,
|
||||
},
|
||||
body: JSON.stringify({email}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
import {NextSeo} from 'next-seo';
|
||||
import React from 'react';
|
||||
import {Navbar} from '../components';
|
||||
|
||||
export default function DataProcessingAgreement() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
|
||||
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
|
||||
openGraph={{
|
||||
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||
description:
|
||||
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
|
||||
}}
|
||||
additionalMetaTags={[
|
||||
{
|
||||
property: 'title',
|
||||
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
|
||||
<Navbar />
|
||||
|
||||
<div className="mx-auto max-w-4xl py-16">
|
||||
{/* Hero */}
|
||||
<div className="mb-12">
|
||||
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
|
||||
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
|
||||
<p className="mt-4 text-base leading-relaxed text-neutral-700">
|
||||
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
|
||||
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
|
||||
accordance with GDPR requirements.
|
||||
</p>
|
||||
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<p className="text-sm leading-relaxed text-blue-900">
|
||||
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
|
||||
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table of Contents */}
|
||||
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
|
||||
<ol className="space-y-2 text-sm text-neutral-700">
|
||||
<li>
|
||||
<a href="#definitions" className="text-blue-600 hover:underline">
|
||||
1. Definitions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#scope" className="text-blue-600 hover:underline">
|
||||
2. Scope & Applicability
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#processing-details" className="text-blue-600 hover:underline">
|
||||
3. Processing Details
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#obligations" className="text-blue-600 hover:underline">
|
||||
4. Data Processor Obligations
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#security" className="text-blue-600 hover:underline">
|
||||
5. Security Measures
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#sub-processors" className="text-blue-600 hover:underline">
|
||||
6. Sub-Processors
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
|
||||
7. Data Subject Rights
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#data-breach" className="text-blue-600 hover:underline">
|
||||
8. Data Breach Notification
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#international-transfers" className="text-blue-600 hover:underline">
|
||||
9. International Data Transfers
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#audits" className="text-blue-600 hover:underline">
|
||||
10. Audits & Compliance
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#deletion" className="text-blue-600 hover:underline">
|
||||
11. Data Deletion & Return
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#liability" className="text-blue-600 hover:underline">
|
||||
12. Liability & Indemnification
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#term" className="text-blue-600 hover:underline">
|
||||
13. Term & Termination
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Content Sections */}
|
||||
<div className="space-y-12">
|
||||
{/* Section 1 */}
|
||||
<section id="definitions" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
|
||||
relating to an identified or identifiable natural person processed via Plunk
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
|
||||
determines the purposes and means of processing Personal Data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
|
||||
processes Personal Data on behalf of the Data Controller
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
|
||||
engaged by Plunk to process Personal Data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
|
||||
whose Personal Data is processed (your contacts/subscribers)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
|
||||
(General Data Protection Regulation)
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 2 */}
|
||||
<section id="scope" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
|
||||
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
|
||||
self-hosting.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
|
||||
processing:
|
||||
</p>
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
|
||||
<li>This DPA takes precedence</li>
|
||||
<li>Then the Terms of Service</li>
|
||||
<li>Then the Privacy Policy</li>
|
||||
</ol>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
|
||||
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 3 */}
|
||||
<section id="processing-details" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
As required by GDPR Article 28(3), the following details describe the processing activities:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Processing of Personal Data necessary to provide email automation, transactional email, marketing
|
||||
campaigns, and workflow automation services.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
|
||||
(which are automatically deleted), and until you request account deletion.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Storing and managing contact databases</li>
|
||||
<li>Sending transactional, marketing, and workflow-triggered emails</li>
|
||||
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
|
||||
<li>Managing email templates and automation workflows</li>
|
||||
<li>Processing email events and webhooks</li>
|
||||
<li>Providing analytics and reporting</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Email addresses (required)</li>
|
||||
<li>Names and custom contact fields (optional, Customer-defined)</li>
|
||||
<li>Email content (subject lines, message bodies, attachments)</li>
|
||||
<li>Subscription status and preferences</li>
|
||||
<li>Email activity data (opens, clicks, bounces, complaints)</li>
|
||||
<li>Timestamps and metadata</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer's email subscribers and contacts</li>
|
||||
<li>Recipients of transactional emails</li>
|
||||
<li>Marketing campaign recipients</li>
|
||||
<li>Workflow automation recipients</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 4 */}
|
||||
<section id="obligations" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk commits to the following obligations as Data Processor:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
|
||||
</li>
|
||||
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
|
||||
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
|
||||
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
|
||||
<li>
|
||||
Not disclose Personal Data to third parties except as required by law or with Customer consent
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
|
||||
<li>
|
||||
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
|
||||
assessment obligations
|
||||
</li>
|
||||
<li>Provide information necessary to demonstrate compliance with Article 28</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 5 */}
|
||||
<section id="security" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
|
||||
ensure a level of security appropriate to the risk:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Industry-standard password hashing (never stored in plaintext)</li>
|
||||
<li>HTTPS-only API access (TLS 1.2+)</li>
|
||||
<li>Database connections encrypted via TLS/SSL</li>
|
||||
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
|
||||
<li>Authentication tokens with limited expiration periods</li>
|
||||
<li>Rate limiting to prevent brute force attacks</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Access controls limiting personnel access to Personal Data</li>
|
||||
<li>Automated bounce and complaint rate monitoring</li>
|
||||
<li>Regular security updates and patching</li>
|
||||
<li>Data segregation by project (multi-tenancy with isolation)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
|
||||
<li>Database and file storage remain within EU/EEA</li>
|
||||
<li>Email delivery may transit non-EU regions (see Section 9)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 6 */}
|
||||
<section id="sub-processors" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
|
||||
</p>
|
||||
|
||||
<div className="mb-6 overflow-x-auto">
|
||||
<table className="min-w-full border border-neutral-300">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Sub-Processor
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Service
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Location
|
||||
</th>
|
||||
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||
Purpose
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white">
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Amazon Web Services (AWS SES)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Global (data in transit only)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Sending emails to recipients
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Payment Processing
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
USA (PCI-DSS compliant)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Billing for paid accounts
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Hetzner Online GmbH
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Infrastructure Hosting
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
EU/EEA (Germany)
|
||||
</td>
|
||||
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||
Database and application hosting
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
|
||||
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
|
||||
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
|
||||
<li>Notice will be sent to the email address associated with your account</li>
|
||||
<li>
|
||||
If you object on reasonable grounds related to data protection, you may terminate your account
|
||||
within 30 days
|
||||
</li>
|
||||
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 7 */}
|
||||
<section id="data-subject-rights" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer can fulfill most Data Subject requests independently via API:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
|
||||
via GET /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
|
||||
data via PATCH /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
|
||||
DELETE /contacts/:id
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
|
||||
subscription status to "unsubscribed"
|
||||
</li>
|
||||
<li>
|
||||
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
|
||||
data in JSON format
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
If Customer cannot fulfill a request via API, contact legal@useplunk.com:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Provide Data Subject's email address and nature of request</li>
|
||||
<li>Plunk will respond within 10 business days with requested information or assistance</li>
|
||||
<li>
|
||||
Customer remains responsible for verifying Data Subject identity before disclosing information
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
|
||||
<li>Deleting a contact will cascade delete associated email activity</li>
|
||||
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 8 */}
|
||||
<section id="data-breach" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
In the event of a Personal Data breach affecting Customer data, Plunk will:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
|
||||
Art. 33)
|
||||
</li>
|
||||
<li>Send notification to the primary email address associated with Customer's account</li>
|
||||
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Notifications will include (to the extent known):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Nature of the breach (what happened)</li>
|
||||
<li>Categories and approximate number of Data Subjects affected</li>
|
||||
<li>Categories and approximate number of Personal Data records affected</li>
|
||||
<li>Likely consequences of the breach</li>
|
||||
<li>Measures taken or proposed to address the breach and mitigate harm</li>
|
||||
<li>Contact point for more information (legal@useplunk.com)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
|
||||
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
|
||||
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section 9 */}
|
||||
<section id="international-transfers" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
|
||||
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
When emails are sent to recipients, Personal Data may transit through non-EU regions:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>AWS SES processes emails globally for delivery purposes</li>
|
||||
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
|
||||
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
|
||||
<li>Stripe is PCI-DSS Level 1 certified</li>
|
||||
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
|
||||
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
|
||||
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
|
||||
transfers per GDPR Article 46.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 10 */}
|
||||
<section id="audits" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
|
||||
Article 28 obligations, including:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>This DPA (publicly available)</li>
|
||||
<li>Privacy Policy describing processing activities</li>
|
||||
<li>Sub-processor list (Section 6 above)</li>
|
||||
<li>Security measures documentation (available upon reasonable request)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Customer may audit Plunk's compliance with this DPA, subject to the following:
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Audits limited to once per year unless required by supervisory authority</li>
|
||||
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
|
||||
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
|
||||
<li>Audits conducted during business hours with minimal disruption to operations</li>
|
||||
<li>Customer bears all costs of audit</li>
|
||||
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
|
||||
compliance documentation (SOC 2, ISO 27001, etc.) where available.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 11 */}
|
||||
<section id="deletion" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
When Customer deletes their Plunk account (via dashboard or by request):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>All Personal Data is immediately deleted from production systems</li>
|
||||
<li>No grace period or recovery window (deletion is permanent)</li>
|
||||
<li>
|
||||
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
|
||||
</li>
|
||||
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
|
||||
<li>Backup data is not accessible or restorable after account deletion</li>
|
||||
<li>Backups are automatically overwritten after retention period</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
|
||||
</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
|
||||
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
|
||||
<li>Legal requests: Data subject to legal holds retained as required by law</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
|
||||
before deleting account. Once deleted, data cannot be recovered.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 12 */}
|
||||
<section id="liability" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
|
||||
</li>
|
||||
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
|
||||
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
|
||||
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
|
||||
<li>Verifying Data Subject identity before fulfilling rights requests</li>
|
||||
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
|
||||
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
|
||||
<li>
|
||||
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
|
||||
82)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>
|
||||
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
|
||||
laws
|
||||
</li>
|
||||
<li>
|
||||
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
|
||||
28
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
|
||||
fault. Each party is liable only for the damage caused by its own GDPR violation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Section 13 */}
|
||||
<section id="term" className="mb-12">
|
||||
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
|
||||
the duration of the Terms of Service.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Customer deletes their Plunk account</li>
|
||||
<li>Terms of Service are terminated</li>
|
||||
<li>All Personal Data has been deleted per Section 11</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
|
||||
necessary to fulfill their purpose.
|
||||
</p>
|
||||
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
|
||||
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
|
||||
<li>Material changes will be notified via email 30 days in advance</li>
|
||||
<li>Continued use of Plunk after changes constitutes acceptance</li>
|
||||
<li>Check "Last Updated" date at top of page for version tracking</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Contact Section */}
|
||||
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
For questions about this DPA, data processing activities, or to exercise audit rights:
|
||||
</p>
|
||||
<p className="mb-2 leading-relaxed text-neutral-700">
|
||||
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
|
||||
</p>
|
||||
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
|
||||
</p>
|
||||
<p className="text-sm text-neutral-600">
|
||||
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
|
||||
legal@useplunk.com with Data Subject's email address and nature of request.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -132,7 +132,7 @@ export default function EmailAPIGuide() {
|
||||
language="javascript"
|
||||
title="Node.js Example"
|
||||
code={`// Using fetch (Node.js 18+ or with node-fetch)
|
||||
const response = await fetch('https://api.useplunk.com/v1/send', {
|
||||
const response = await fetch('https://next-api.useplunk.com/v1/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -167,7 +167,7 @@ if (response.ok) {
|
||||
code={`import requests
|
||||
|
||||
response = requests.post(
|
||||
'https://api.useplunk.com/v1/send',
|
||||
'https://next-api.useplunk.com/v1/send',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY'
|
||||
@@ -195,7 +195,7 @@ else:
|
||||
language="php"
|
||||
title="PHP Example"
|
||||
code={`<?php
|
||||
$ch = curl_init('https://api.useplunk.com/v1/send');
|
||||
$ch = curl_init('https://next-api.useplunk.com/v1/send');
|
||||
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
@@ -235,7 +235,7 @@ if ($httpCode === 200) {
|
||||
code={`require 'net/http'
|
||||
require 'json'
|
||||
|
||||
uri = URI('https://api.useplunk.com/v1/send')
|
||||
uri = URI('https://next-api.useplunk.com/v1/send')
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
@@ -295,7 +295,7 @@ func main() {
|
||||
|
||||
jsonData, _ := json.Marshal(email)
|
||||
|
||||
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
||||
req, _ := http.NewRequest("POST", "https://next-api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -306,9 +306,7 @@ export default function EmailDeliverability() {
|
||||
{/* Monitoring */}
|
||||
<section id="monitoring" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
Track these metrics to stay on top of deliverability:
|
||||
</p>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">Track these metrics to stay on top of deliverability:</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="15 min"
|
||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="11 min"
|
||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user